feat(products): implement professional product management experience
Products dashboard (real total/published/drafts/out-of-stock/hidden/missing-images/missing-SEO/low-quality counts, recently-edited list, recommended next action); list gains table/grid view toggle, density, saved column visibility and saved sort (persisted via LocalStorageService), plus real bulk assign-category/assign-tags/duplicate/CSV-export alongside existing publish/hide/delete; per-row and per-editor reusable ProductHealthWidget (images/SEO/price/category/description/inventory checklist + completion %); editor reorganized into General/Media/Pricing/Inventory/Categories/Attributes/SEO/Visibility/Advanced tabs, media now uses the shared MediaPickerComponent/ImageFieldComponent (primary image + reorderable gallery) instead of raw URL textareas, specifications/attributes/variants moved off pipe-delimited textareas onto the shared KeyValueEditorComponent, SEO tab explains fields in plain language with a live search-result preview, inventory relabeled in business language, toggles/badges use the shared Toggle/Badge components. Filled in the adminProducts i18n namespace (previously ~98% missing, rendering raw translation keys) across en/ru/hy.
This commit is contained in:
@@ -1,139 +1,254 @@
|
|||||||
<section class="form-card">
|
<section class="form-card">
|
||||||
<div class="grid two">
|
<div class="form-card__header">
|
||||||
<app-form-field [label]="'adminProducts.name' | translate">
|
<app-product-health-widget [items]="healthItems()" [completionPercent]="health.completionPercent" />
|
||||||
<app-input [ngModel]="product.name" (ngModelChange)="updateField('name', $event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<app-form-field [label]="'adminProducts.slug' | translate">
|
|
||||||
<app-input [ngModel]="product.slug" (ngModelChange)="updateField('slug', $event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<app-form-field [label]="'backoffice.sku' | translate">
|
|
||||||
<app-input [ngModel]="product.sku" (ngModelChange)="updateField('sku', $event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<app-form-field [label]="'adminProducts.barcode' | translate">
|
|
||||||
<app-input [ngModel]="product.barcode" (ngModelChange)="updateField('barcode', $event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<app-form-field [label]="'adminProducts.brand' | translate">
|
|
||||||
<app-input [ngModel]="product.brand" (ngModelChange)="updateField('brand', $event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<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>
|
|
||||||
<app-form-field [label]="'adminProducts.priority' | translate">
|
|
||||||
<app-input type="number" [ngModel]="product.priority" (ngModelChange)="updateField('priority', +$event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<label class="check"><input type="checkbox" [checked]="product.visible" (change)="updateField('visible', $any($event.target).checked)" /><span>{{ 'adminProducts.visible' | translate }}</span></label>
|
|
||||||
<label class="check"><input type="checkbox" [checked]="product.archived" (change)="updateField('archived', $any($event.target).checked)" /><span>{{ 'adminProducts.archived' | translate }}</span></label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>{{ 'adminProducts.media' | translate }}</h3>
|
<div class="form-card__tabs" role="tablist">
|
||||||
<div class="grid one">
|
@for (group of groups; track group.id) {
|
||||||
<label><span>{{ 'adminProducts.images' | translate }}</span><textarea rows="3" [ngModel]="product.media.images.join('\n')" (ngModelChange)="updateList('images', $event)"></textarea></label>
|
<button
|
||||||
<label><span>{{ 'adminProducts.gallery' | translate }}</span>
|
type="button"
|
||||||
<div class="gallery-grid">
|
role="tab"
|
||||||
@for (url of product.media.gallery; track $index) {
|
[attr.aria-selected]="activeGroup() === group.id"
|
||||||
<div class="gallery-item"><img [src]="url" alt="" /><button type="button" (click)="removeGalleryImage($index)">×</button></div>
|
[class.form-card__tab--active]="activeGroup() === group.id"
|
||||||
}
|
class="form-card__tab"
|
||||||
<app-button variant="secondary" size="sm" (click)="openMediaPicker()">{{ 'adminCategories.chooseImage' | translate }}</app-button>
|
(click)="setGroup(group.id)"
|
||||||
</div>
|
>
|
||||||
<app-media-picker [open]="mediaPickerOpen" (selected)="addGalleryImage($event)" (closed)="mediaPickerOpen = false" />
|
{{ group.labelKey | translate }}
|
||||||
</label>
|
@if (group.id === 'media' && !health.hasImages) { <app-badge variant="warning">!</app-badge> }
|
||||||
<label><span>{{ 'adminProducts.videos' | translate }}</span><textarea rows="3" [ngModel]="product.media.videos.join('\n')" (ngModelChange)="updateList('videos', $event)"></textarea></label>
|
@if (group.id === 'seo' && !health.hasSeo) { <app-badge variant="warning">!</app-badge> }
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<h3>{{ 'adminProducts.pricing' | translate }}</h3>
|
|
||||||
<div class="grid three">
|
|
||||||
<app-form-field [label]="'backoffice.price' | translate">
|
|
||||||
<app-input type="number" [ngModel]="product.price" (ngModelChange)="updateField('price', +$event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<app-form-field [label]="'adminProducts.discount' | translate">
|
|
||||||
<app-input type="number" [ngModel]="product.discount" (ngModelChange)="updateField('discount', +$event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<app-form-field [label]="'adminProducts.currency' | translate">
|
|
||||||
<app-input [ngModel]="product.currency" (ngModelChange)="updateField('currency', $event)" />
|
|
||||||
</app-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>{{ 'adminProducts.inventory' | translate }}</h3>
|
|
||||||
<div class="grid three">
|
|
||||||
<app-form-field [label]="'adminProducts.quantity' | translate">
|
|
||||||
<app-input type="number" [ngModel]="product.quantity" (ngModelChange)="updateField('quantity', +$event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<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>
|
|
||||||
<app-form-field [label]="'adminProducts.availability' | translate">
|
|
||||||
<app-input [ngModel]="product.availability" (ngModelChange)="updateField('availability', $event)" />
|
|
||||||
</app-form-field>
|
|
||||||
</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 locales; track locale) {
|
|
||||||
<div class="grid two sub-block">
|
|
||||||
<app-form-field [label]="(('adminProducts.name' | translate) + ' ' + locale)">
|
|
||||||
<app-input [ngModel]="product.translations[locale]?.name || ''" (ngModelChange)="updateTranslation(locale, 'name', $event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<app-form-field [label]="(('adminProducts.shortDescription' | translate) + ' ' + locale)">
|
|
||||||
<app-input [ngModel]="product.translations[locale]?.shortDescription || ''" (ngModelChange)="updateTranslation(locale, 'shortDescription', $event)" />
|
|
||||||
</app-form-field>
|
|
||||||
<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">
|
|
||||||
<app-form-field [label]="'adminProducts.metaTitle' | translate">
|
|
||||||
<app-input [ngModel]="product.seo.metaTitle" (ngModelChange)="productChange.emit({ seo: { ...product.seo, metaTitle: $event } })" />
|
|
||||||
</app-form-field>
|
|
||||||
<label><span>{{ 'adminProducts.metaDescription' | translate }}</span><textarea rows="3" [ngModel]="product.seo.metaDescription" (ngModelChange)="productChange.emit({ seo: { ...product.seo, metaDescription: $event } })"></textarea></label>
|
|
||||||
<app-form-field [label]="'adminProducts.keywords' | translate">
|
|
||||||
<app-input [ngModel]="product.seo.keywords" (ngModelChange)="productChange.emit({ seo: { ...product.seo, keywords: $event } })" />
|
|
||||||
</app-form-field>
|
|
||||||
</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>
|
|
||||||
<app-form-field class="full" [label]="'adminProducts.badges' | translate">
|
|
||||||
<app-input [ngModel]="product.badges.join(', ')" (ngModelChange)="updateList('badges', $event)" />
|
|
||||||
</app-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>{{ 'adminProducts.variants' | translate }}</h3>
|
|
||||||
<div class="grid one">
|
|
||||||
<label><span>{{ 'adminProducts.variantsHint' | translate }}</span><textarea rows="4" [ngModel]="joinVariants(product.variants)" (ngModelChange)="updateVariants($event)"></textarea></label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>{{ 'adminProducts.relatedProducts' | translate }}</h3>
|
|
||||||
<div class="related-grid">
|
|
||||||
@for (other of otherProducts(); track other.id) {
|
|
||||||
<label class="check"><input type="checkbox" [checked]="product.relatedProductIds.includes(other.id)" (change)="toggleRelated(other.id, $any($event.target).checked)" /><span>{{ other.name }}</span></label>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>{{ 'adminProducts.preview' | translate }}</h3>
|
<div class="form-card__panel">
|
||||||
<p class="price-preview">{{ product.name || ('adminProducts.name' | translate) }} — {{ finalPrice() }} {{ product.currency }}
|
@if (activeGroup() === 'general') {
|
||||||
@if (product.discount > 0) { <s>{{ product.price }} {{ product.currency }}</s> }
|
<div class="grid two">
|
||||||
</p>
|
<app-form-field [label]="'adminProducts.name' | translate">
|
||||||
|
<app-input [ngModel]="product.name" (ngModelChange)="updateField('name', $event)" />
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'adminProducts.slug' | translate">
|
||||||
|
<app-input [ngModel]="product.slug" (ngModelChange)="updateField('slug', $event)" />
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'backoffice.sku' | translate">
|
||||||
|
<app-input [ngModel]="product.sku" (ngModelChange)="updateField('sku', $event)" />
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'adminProducts.barcode' | translate">
|
||||||
|
<app-input [ngModel]="product.barcode" (ngModelChange)="updateField('barcode', $event)" />
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'adminProducts.brand' | translate">
|
||||||
|
<app-input [ngModel]="product.brand" (ngModelChange)="updateField('brand', $event)" />
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'adminProducts.priority' | translate">
|
||||||
|
<app-input type="number" [ngModel]="product.priority" (ngModelChange)="updateField('priority', +$event)" />
|
||||||
|
</app-form-field>
|
||||||
|
</div>
|
||||||
|
<app-form-field [label]="'adminProducts.shortDescription' | translate">
|
||||||
|
<app-input [ngModel]="product.shortDescription" (ngModelChange)="updateField('shortDescription', $event)" />
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
<h3>{{ 'adminProducts.customer' | translate }}</h3>
|
<h4 class="form-card__subheading">{{ 'adminProducts.translations' | translate }}</h4>
|
||||||
<div class="readonly-grid">
|
@for (locale of locales; track locale) {
|
||||||
<article>
|
<div class="grid two sub-block">
|
||||||
<h4>{{ 'adminProducts.reviewsReadonly' | translate }}</h4>
|
<app-form-field [label]="(('adminProducts.name' | translate) + ' — ' + locale)">
|
||||||
@if (product.reviews.length === 0) { <p>{{ 'adminProducts.noReviews' | translate }}</p> }
|
<app-input [ngModel]="product.translations[locale]?.name || ''" (ngModelChange)="updateTranslation(locale, 'name', $event)" />
|
||||||
@for (review of product.reviews; track review.id) { <p><strong>{{ review.author }}</strong> · {{ review.rating }}/5<br />{{ review.text }}</p> }
|
</app-form-field>
|
||||||
</article>
|
<app-form-field [label]="(('adminProducts.shortDescription' | translate) + ' — ' + locale)">
|
||||||
<article>
|
<app-input [ngModel]="product.translations[locale]?.shortDescription || ''" (ngModelChange)="updateTranslation(locale, 'shortDescription', $event)" />
|
||||||
<h4>{{ 'adminProducts.questionsReadonly' | translate }}</h4>
|
</app-form-field>
|
||||||
@if (product.questions.length === 0) { <p>{{ 'adminProducts.noQuestions' | translate }}</p> }
|
</div>
|
||||||
@for (question of product.questions; track question.id) { <p><strong>{{ question.question }}</strong><br />{{ question.answer || '-' }}</p> }
|
}
|
||||||
</article>
|
}
|
||||||
|
|
||||||
|
@if (activeGroup() === 'media') {
|
||||||
|
<div class="grid one">
|
||||||
|
<app-form-field [label]="'adminProducts.primaryImage' | translate" [hint]="'adminProducts.primaryImageHint' | translate">
|
||||||
|
<app-image-field [value]="product.media.images[0] || ''" (valueChange)="setPrimaryImage($event)" />
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
|
<app-form-field [label]="'adminProducts.gallery' | translate" [hint]="'adminProducts.galleryHint' | translate">
|
||||||
|
<div class="gallery-grid">
|
||||||
|
@for (url of product.media.gallery; track $index) {
|
||||||
|
<div class="gallery-item">
|
||||||
|
<img [src]="url" alt="" loading="lazy" />
|
||||||
|
<div class="gallery-item__actions">
|
||||||
|
<button type="button" (click)="moveGalleryImage($index, -1)" [attr.aria-label]="'adminProducts.moveLeft' | translate">←</button>
|
||||||
|
<button type="button" (click)="moveGalleryImage($index, 1)" [attr.aria-label]="'adminProducts.moveRight' | translate">→</button>
|
||||||
|
<button type="button" (click)="removeGalleryImage($index)" [attr.aria-label]="'mediaLibrary.delete' | translate">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<app-button variant="secondary" size="sm" (click)="openMediaPicker()">{{ 'adminCategories.chooseImage' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
<app-media-picker [open]="mediaPickerOpen" (selected)="addGalleryImage($event)" (closed)="mediaPickerOpen = false" />
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
|
<label class="full"><span>{{ 'adminProducts.videos' | translate }}</span><textarea rows="3" [ngModel]="product.media.videos.join('\n')" (ngModelChange)="updateVideos($event)"></textarea></label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeGroup() === 'pricing') {
|
||||||
|
<div class="grid three">
|
||||||
|
<app-form-field [label]="'backoffice.price' | translate">
|
||||||
|
<app-input type="number" [ngModel]="product.price" (ngModelChange)="updateField('price', +$event)" />
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'adminProducts.discount' | translate">
|
||||||
|
<app-input type="number" [ngModel]="product.discount" (ngModelChange)="updateField('discount', +$event)" />
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'adminProducts.currency' | translate">
|
||||||
|
<app-input [ngModel]="product.currency" (ngModelChange)="updateField('currency', $event)" />
|
||||||
|
</app-form-field>
|
||||||
|
</div>
|
||||||
|
<p class="price-preview">{{ product.name || ('adminProducts.name' | translate) }} — {{ finalPrice() }} {{ product.currency }}
|
||||||
|
@if (product.discount > 0) { <s>{{ product.price }} {{ product.currency }}</s> }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeGroup() === 'inventory') {
|
||||||
|
<div class="grid three">
|
||||||
|
<app-form-field [label]="'adminProducts.stockQuantity' | translate" [hint]="'adminProducts.stockQuantityHint' | translate">
|
||||||
|
<app-input type="number" [ngModel]="product.quantity" (ngModelChange)="updateField('quantity', +$event)" />
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'adminProducts.availabilityStatus' | translate" [hint]="'adminProducts.availabilityStatusHint' | translate">
|
||||||
|
<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>
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'adminProducts.availabilityNote' | translate" [hint]="'adminProducts.availabilityNoteHint' | translate">
|
||||||
|
<app-input [ngModel]="product.availability" (ngModelChange)="updateField('availability', $event)" />
|
||||||
|
</app-form-field>
|
||||||
|
</div>
|
||||||
|
<p class="form-card__explain">{{ 'adminProducts.reservedNote' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeGroup() === 'categories') {
|
||||||
|
<app-form-field [label]="'adminProducts.category' | translate">
|
||||||
|
<select [ngModel]="product.categoryId" (ngModelChange)="updateField('categoryId', $event)">
|
||||||
|
@for (category of categories; track category.id) { <option [ngValue]="category.id">{{ category.title }}</option> }
|
||||||
|
</select>
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
|
<h4 class="form-card__subheading">{{ 'adminProducts.relatedProducts' | translate }}</h4>
|
||||||
|
<div class="related-grid">
|
||||||
|
@for (other of otherProducts(); track other.id) {
|
||||||
|
<label class="check"><input type="checkbox" [checked]="product.relatedProductIds.includes(other.id)" (change)="toggleRelated(other.id, $any($event.target).checked)" /><span>{{ other.name }}</span></label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeGroup() === 'attributes') {
|
||||||
|
<details class="form-card__group" open>
|
||||||
|
<summary>{{ 'adminProducts.specifications' | translate }}</summary>
|
||||||
|
<app-key-value-editor
|
||||||
|
[rows]="product.specifications"
|
||||||
|
[createRow]="createSpecRow"
|
||||||
|
[addLabel]="'adminProducts.addRow' | translate"
|
||||||
|
[removeLabel]="'adminProducts.removeRow' | translate"
|
||||||
|
(rowsChange)="updateSpecifications($event)"
|
||||||
|
>
|
||||||
|
<ng-template let-row let-i="index">
|
||||||
|
<app-input [ngModel]="row.key" [placeholder]="'adminProducts.rowKey' | translate" (ngModelChange)="updateSpecificationField(i, 'key', $event)" />
|
||||||
|
<app-input [ngModel]="row.value" [placeholder]="'adminProducts.rowValue' | translate" (ngModelChange)="updateSpecificationField(i, 'value', $event)" />
|
||||||
|
</ng-template>
|
||||||
|
</app-key-value-editor>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="form-card__group">
|
||||||
|
<summary>{{ 'adminProducts.attributes' | translate }}</summary>
|
||||||
|
<app-key-value-editor
|
||||||
|
[rows]="product.attributes"
|
||||||
|
[createRow]="createAttributeRow"
|
||||||
|
[addLabel]="'adminProducts.addRow' | translate"
|
||||||
|
[removeLabel]="'adminProducts.removeRow' | translate"
|
||||||
|
(rowsChange)="updateAttributes($event)"
|
||||||
|
>
|
||||||
|
<ng-template let-row let-i="index">
|
||||||
|
<app-input [ngModel]="row.key" [placeholder]="'adminProducts.rowKey' | translate" (ngModelChange)="updateAttributeField(i, 'key', $event)" />
|
||||||
|
<app-input [ngModel]="row.value" [placeholder]="'adminProducts.rowValue' | translate" (ngModelChange)="updateAttributeField(i, 'value', $event)" />
|
||||||
|
</ng-template>
|
||||||
|
</app-key-value-editor>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="form-card__group">
|
||||||
|
<summary>{{ 'adminProducts.variants' | translate }}</summary>
|
||||||
|
<p class="form-card__explain">{{ 'adminProducts.variantsHint' | translate }}</p>
|
||||||
|
<app-key-value-editor
|
||||||
|
[rows]="product.variants"
|
||||||
|
[createRow]="createVariantRow"
|
||||||
|
[addLabel]="'adminProducts.addVariant' | translate"
|
||||||
|
[removeLabel]="'adminProducts.removeRow' | translate"
|
||||||
|
(rowsChange)="updateVariants($event)"
|
||||||
|
>
|
||||||
|
<ng-template let-row let-i="index">
|
||||||
|
<app-input [ngModel]="row.name" [placeholder]="'adminProducts.variantName' | translate" (ngModelChange)="updateVariantField(i, { name: $event })" />
|
||||||
|
<app-input type="number" [ngModel]="row.price" [placeholder]="'backoffice.price' | translate" (ngModelChange)="updateVariantField(i, { price: +$event })" />
|
||||||
|
<app-input type="number" [ngModel]="row.quantity" [placeholder]="'adminProducts.stockQuantity' | translate" (ngModelChange)="updateVariantField(i, { quantity: +$event })" />
|
||||||
|
</ng-template>
|
||||||
|
</app-key-value-editor>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeGroup() === 'seo') {
|
||||||
|
<p class="form-card__explain">{{ 'adminProducts.seoExplain' | translate }}</p>
|
||||||
|
<app-form-field [label]="'adminProducts.searchTitle' | translate" [hint]="'adminProducts.searchTitleHint' | translate" [error]="!product.seo.metaTitle.trim() ? ('adminProducts.seoMissingTitle' | translate) : null">
|
||||||
|
<app-input [ngModel]="product.seo.metaTitle" (ngModelChange)="productChange.emit({ seo: { ...product.seo, metaTitle: $event } })" />
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'adminProducts.searchDescription' | translate" [hint]="'adminProducts.searchDescriptionHint' | translate" [error]="!product.seo.metaDescription.trim() ? ('adminProducts.seoMissingDescription' | translate) : null">
|
||||||
|
<app-input [ngModel]="product.seo.metaDescription" (ngModelChange)="productChange.emit({ seo: { ...product.seo, metaDescription: $event } })" />
|
||||||
|
</app-form-field>
|
||||||
|
<app-form-field [label]="'adminProducts.keywords' | translate">
|
||||||
|
<app-input [ngModel]="product.seo.keywords" (ngModelChange)="productChange.emit({ seo: { ...product.seo, keywords: $event } })" />
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
|
<div class="seo-preview">
|
||||||
|
<p class="seo-preview__title">{{ product.seo.metaTitle || product.name }}</p>
|
||||||
|
<p class="seo-preview__url">yourstore.com/{{ product.slug }}</p>
|
||||||
|
<p class="seo-preview__description">{{ product.seo.metaDescription || product.shortDescription }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeGroup() === 'visibility') {
|
||||||
|
<div class="grid two">
|
||||||
|
<label class="toggle-row"><app-toggle [ngModel]="product.visible" (ngModelChange)="updateField('visible', $event)" [ariaLabel]="'adminProducts.visible' | translate" /><span>{{ 'adminProducts.visible' | translate }}</span></label>
|
||||||
|
<label class="toggle-row"><app-toggle [ngModel]="product.archived" (ngModelChange)="updateField('archived', $event)" [ariaLabel]="'adminProducts.archived' | translate" /><span>{{ 'adminProducts.archived' | translate }}</span></label>
|
||||||
|
<label class="toggle-row"><app-toggle [ngModel]="product.featured" (ngModelChange)="updateField('featured', $event)" [ariaLabel]="'adminProducts.featured' | translate" /><span>{{ 'adminProducts.featured' | translate }}</span></label>
|
||||||
|
<label class="toggle-row"><app-toggle [ngModel]="product.recommended" (ngModelChange)="updateField('recommended', $event)" [ariaLabel]="'adminProducts.recommended' | translate" /><span>{{ 'adminProducts.recommended' | translate }}</span></label>
|
||||||
|
<label class="toggle-row"><app-toggle [ngModel]="product.isNew" (ngModelChange)="updateField('isNew', $event)" [ariaLabel]="'adminProducts.new' | translate" /><span>{{ 'adminProducts.new' | translate }}</span></label>
|
||||||
|
<label class="toggle-row"><app-toggle [ngModel]="product.bestseller" (ngModelChange)="updateField('bestseller', $event)" [ariaLabel]="'adminProducts.bestseller' | translate" /><span>{{ 'adminProducts.bestseller' | translate }}</span></label>
|
||||||
|
</div>
|
||||||
|
<app-form-field [label]="'adminProducts.badges' | translate">
|
||||||
|
<app-input [ngModel]="product.badges.join(', ')" (ngModelChange)="updateBadges($event)" />
|
||||||
|
</app-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeGroup() === 'advanced') {
|
||||||
|
<h4 class="form-card__subheading">{{ 'adminProducts.htmlDescription' | translate }}</h4>
|
||||||
|
<app-marketplace-html-editor [html]="product.htmlDescription" (htmlChange)="updateField('htmlDescription', $event)" />
|
||||||
|
|
||||||
|
@for (locale of locales; track locale) {
|
||||||
|
<label class="full sub-block"><span>{{ 'adminProducts.htmlDescription' | translate }} {{ locale }}</span>
|
||||||
|
<app-marketplace-html-editor
|
||||||
|
[html]="product.translations[locale]?.htmlDescription || ''"
|
||||||
|
(htmlChange)="updateTranslation(locale, 'htmlDescription', $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h4 class="form-card__subheading">{{ 'adminProducts.customer' | translate }}</h4>
|
||||||
|
<div class="readonly-grid">
|
||||||
|
<article>
|
||||||
|
<h5>{{ 'adminProducts.reviewsReadonly' | translate }}</h5>
|
||||||
|
@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>
|
||||||
|
<h5>{{ 'adminProducts.questionsReadonly' | translate }}</h5>
|
||||||
|
@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>
|
</div>
|
||||||
|
|
||||||
<div class="actions"><app-button variant="primary" (click)="save.emit()">{{ 'adminProducts.save' | translate }}</app-button></div>
|
<div class="actions"><app-button variant="primary" (click)="save.emit()">{{ 'adminProducts.save' | translate }}</app-button></div>
|
||||||
|
|||||||
@@ -11,36 +11,24 @@ input[type='checkbox'] { width: auto; }
|
|||||||
.sub-block { border-top: 1px dashed #d9e2e1; padding-top: 12px; }
|
.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 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
.readonly-grid article { border: 1px solid #ececec; border-radius: 12px; padding: 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-grid { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start; }
|
||||||
.gallery-item { position: relative; width: 64px; height: 64px; }
|
.gallery-item { display: grid; gap: 4px; width: 72px; }
|
||||||
.gallery-item img { width: 100%; height: 100%; object-fit: cover; border-radius: 8px; border: 1px solid var(--border-color, #d3dad9); }
|
.gallery-item img { width: 72px; height: 72px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border-color, #d3dad9); }
|
||||||
.gallery-item button {
|
.gallery-item__actions { display: flex; justify-content: space-between; gap: 2px; }
|
||||||
position: absolute;
|
.gallery-item__actions button {
|
||||||
top: -6px;
|
flex: 1;
|
||||||
right: -6px;
|
min-width: 0;
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
border: none;
|
||||||
background: #d9433c;
|
border-radius: 6px;
|
||||||
color: #fff;
|
background: var(--surface-muted, #eef2f0);
|
||||||
|
color: var(--text-secondary, #5f6e6a);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform var(--transition-fast, 120ms ease), background-color var(--transition-fast, 120ms ease);
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 4px;
|
||||||
|
|
||||||
&::before {
|
&:hover, &:focus-visible {
|
||||||
content: '';
|
background: var(--brand-primary, #1e8a6e);
|
||||||
position: absolute;
|
color: #fff;
|
||||||
inset: -12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #b8352f;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid #d9433c;
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.related-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
.related-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
||||||
@@ -48,3 +36,39 @@ input[type='checkbox'] { width: auto; }
|
|||||||
.actions { display: flex; justify-content: flex-end; }
|
.actions { display: flex; justify-content: flex-end; }
|
||||||
@media (max-width: 700px) { .related-grid { grid-template-columns: 1fr; } }
|
@media (max-width: 700px) { .related-grid { grid-template-columns: 1fr; } }
|
||||||
@media (max-width: 900px) { .grid.two, .grid.three, .readonly-grid { grid-template-columns: 1fr; } }
|
@media (max-width: 900px) { .grid.two, .grid.three, .readonly-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.form-card__header { display: grid; gap: 8px; }
|
||||||
|
|
||||||
|
.form-card__tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--border-subtle, #e7ece9); }
|
||||||
|
.form-card__tab {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #5f6e6a);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&:focus-visible { outline: 2px solid var(--brand-primary, #1e8a6e); outline-offset: 2px; }
|
||||||
|
}
|
||||||
|
.form-card__tab--active { color: var(--brand-primary, #1e8a6e); background: var(--surface-muted, #eef2f0); }
|
||||||
|
.form-card__panel { min-height: 200px; display: grid; gap: 14px; }
|
||||||
|
.form-card__subheading { margin: 8px 0 0; font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-secondary, #5f6e6a); }
|
||||||
|
.form-card__explain { margin: 0; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
|
||||||
|
.form-card__group { border: 1px solid var(--border-subtle, #e7ece9); border-radius: 10px; padding: 10px 14px; }
|
||||||
|
.form-card__group summary { cursor: pointer; font-weight: 700; color: var(--text-primary, #1e3c38); }
|
||||||
|
.toggle-row { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
||||||
|
|
||||||
|
.seo-preview {
|
||||||
|
border: 1px solid var(--border-subtle, #e7ece9);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.seo-preview__title { margin: 0; color: #1a0dab; font-size: 1rem; }
|
||||||
|
.seo-preview__url { margin: 0; color: #006621; font-size: 0.8rem; }
|
||||||
|
.seo-preview__description { margin: 0; color: var(--text-secondary, #5f6e6a); font-size: 0.85rem; }
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AdminProduct, AdminProductCategoryOption, AdminProductVariant } from '../models/admin-product.model';
|
import { AdminProduct, AdminProductAttribute, AdminProductCategoryOption, AdminProductSpecification, AdminProductVariant } from '../models/admin-product.model';
|
||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
import { InputComponent } from '../../../../shared/ui/input/input.component';
|
import { InputComponent } from '../../../../shared/ui/input/input.component';
|
||||||
import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field.component';
|
||||||
|
import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component';
|
||||||
|
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||||
|
import { KeyValueEditorComponent } from '../../../../shared/ui/key-value-editor/key-value-editor.component';
|
||||||
|
import { ImageFieldComponent } from '../../../../shared/ui/image-field/image-field.component';
|
||||||
import { MediaPickerComponent } from '../../../../shared/media/media-picker/media-picker.component';
|
import { MediaPickerComponent } from '../../../../shared/media/media-picker/media-picker.component';
|
||||||
import { MediaAsset } from '../../../../core/media/models/media-asset.model';
|
import { MediaAsset } from '../../../../core/media/models/media-asset.model';
|
||||||
|
import { MarketplaceHtmlEditorComponent } from '../../../project-editor/components/html-editor/marketplace-html-editor.component';
|
||||||
|
import { AdminProductHealth } from '../facade/admin-products.facade';
|
||||||
|
import { ProductHealthWidgetComponent, ProductHealthItem } from './product-health-widget/product-health-widget.component';
|
||||||
|
|
||||||
|
export type AdminProductEditorGroup = 'general' | 'media' | 'pricing' | 'inventory' | 'categories' | 'attributes' | 'seo' | 'visibility' | 'advanced';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-product-form',
|
selector: 'app-admin-product-form',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent, MediaPickerComponent],
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
TranslatePipe,
|
||||||
|
ButtonComponent,
|
||||||
|
InputComponent,
|
||||||
|
FormFieldComponent,
|
||||||
|
ToggleComponent,
|
||||||
|
BadgeComponent,
|
||||||
|
KeyValueEditorComponent,
|
||||||
|
ImageFieldComponent,
|
||||||
|
MediaPickerComponent,
|
||||||
|
MarketplaceHtmlEditorComponent,
|
||||||
|
ProductHealthWidgetComponent,
|
||||||
|
],
|
||||||
templateUrl: './admin-product-form.component.html',
|
templateUrl: './admin-product-form.component.html',
|
||||||
styleUrls: ['./admin-product-form.component.scss'],
|
styleUrls: ['./admin-product-form.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -22,12 +44,40 @@ export class AdminProductFormComponent {
|
|||||||
@Input() allProducts: AdminProduct[] = [];
|
@Input() allProducts: AdminProduct[] = [];
|
||||||
@Input() locales: string[] = ['en'];
|
@Input() locales: string[] = ['en'];
|
||||||
@Input() mode: 'create' | 'edit' | 'duplicate' = 'create';
|
@Input() mode: 'create' | 'edit' | 'duplicate' = 'create';
|
||||||
|
@Input() health!: AdminProductHealth;
|
||||||
protected mediaPickerOpen = false;
|
|
||||||
|
|
||||||
@Output() productChange = new EventEmitter<Partial<AdminProduct>>();
|
@Output() productChange = new EventEmitter<Partial<AdminProduct>>();
|
||||||
@Output() save = new EventEmitter<void>();
|
@Output() save = new EventEmitter<void>();
|
||||||
|
|
||||||
|
protected mediaPickerOpen = false;
|
||||||
|
|
||||||
|
readonly groups: { id: AdminProductEditorGroup; labelKey: string }[] = [
|
||||||
|
{ id: 'general', labelKey: 'adminProducts.groupGeneral' },
|
||||||
|
{ id: 'media', labelKey: 'adminProducts.groupMedia' },
|
||||||
|
{ id: 'pricing', labelKey: 'adminProducts.groupPricing' },
|
||||||
|
{ id: 'inventory', labelKey: 'adminProducts.groupInventory' },
|
||||||
|
{ id: 'categories', labelKey: 'adminProducts.groupCategories' },
|
||||||
|
{ id: 'attributes', labelKey: 'adminProducts.groupAttributes' },
|
||||||
|
{ id: 'seo', labelKey: 'adminProducts.groupSeo' },
|
||||||
|
{ id: 'visibility', labelKey: 'adminProducts.groupVisibility' },
|
||||||
|
{ id: 'advanced', labelKey: 'adminProducts.groupAdvanced' },
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly activeGroup = signal<AdminProductEditorGroup>('general');
|
||||||
|
|
||||||
|
setGroup(group: AdminProductEditorGroup): void {
|
||||||
|
this.activeGroup.set(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly healthItems = computed<ProductHealthItem[]>(() => [
|
||||||
|
{ labelKey: 'adminProducts.healthImages', done: this.health.hasImages },
|
||||||
|
{ labelKey: 'adminProducts.healthSeo', done: this.health.hasSeo },
|
||||||
|
{ labelKey: 'adminProducts.healthPrice', done: this.health.hasPrice },
|
||||||
|
{ labelKey: 'adminProducts.healthCategory', done: this.health.hasCategory },
|
||||||
|
{ labelKey: 'adminProducts.healthDescription', done: this.health.hasDescription },
|
||||||
|
{ labelKey: 'adminProducts.healthInventory', done: this.health.hasInventory },
|
||||||
|
]);
|
||||||
|
|
||||||
updateField<K extends keyof AdminProduct>(key: K, value: AdminProduct[K]): void {
|
updateField<K extends keyof AdminProduct>(key: K, value: AdminProduct[K]): void {
|
||||||
this.productChange.emit({ [key]: value } as Partial<AdminProduct>);
|
this.productChange.emit({ [key]: value } as Partial<AdminProduct>);
|
||||||
}
|
}
|
||||||
@@ -44,38 +94,36 @@ export class AdminProductFormComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateList(type: 'specifications' | 'attributes' | 'badges' | 'images' | 'gallery' | 'videos', value: string): void {
|
updateBadges(value: string): void {
|
||||||
if (type === 'badges') {
|
this.productChange.emit({ badges: value.split(',').map(item => item.trim()).filter(Boolean) });
|
||||||
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 {
|
readonly createSpecRow = () => ({ key: '', value: '' });
|
||||||
return items.map(item => `${item.key}|${item.value}`).join('\n');
|
readonly createAttributeRow = () => ({ key: '', value: '' });
|
||||||
|
readonly createVariantRow = (): AdminProductVariant => ({ name: '', price: 0, quantity: 0 });
|
||||||
|
|
||||||
|
updateSpecifications(rows: AdminProductSpecification[]): void {
|
||||||
|
this.productChange.emit({ specifications: rows });
|
||||||
}
|
}
|
||||||
|
|
||||||
updateVariants(value: string): void {
|
updateSpecificationField(index: number, field: 'key' | 'value', value: string): void {
|
||||||
const variants: AdminProductVariant[] = value.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
|
this.updateSpecifications(this.product.specifications.map((row, i) => i === index ? { ...row, [field]: value } : row));
|
||||||
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 {
|
updateAttributes(rows: AdminProductAttribute[]): void {
|
||||||
return variants.map(variant => `${variant.name}|${variant.price}|${variant.quantity}`).join('\n');
|
this.productChange.emit({ attributes: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAttributeField(index: number, field: 'key' | 'value', value: string): void {
|
||||||
|
this.updateAttributes(this.product.attributes.map((row, i) => i === index ? { ...row, [field]: value } : row));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVariants(rows: AdminProductVariant[]): void {
|
||||||
|
this.productChange.emit({ variants: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVariantField(index: number, patch: Partial<AdminProductVariant>): void {
|
||||||
|
this.updateVariants(this.product.variants.map((row, i) => i === index ? { ...row, ...patch } : row));
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleRelated(id: string, checked: boolean): void {
|
toggleRelated(id: string, checked: boolean): void {
|
||||||
@@ -87,6 +135,14 @@ export class AdminProductFormComponent {
|
|||||||
return this.allProducts.filter(item => item.id !== this.product.id);
|
return this.allProducts.filter(item => item.id !== this.product.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPrimaryImage(url: string): void {
|
||||||
|
this.productChange.emit({ media: { ...this.product.media, images: url ? [url] : [] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVideos(value: string): void {
|
||||||
|
this.productChange.emit({ media: { ...this.product.media, videos: value.split('\n').map(item => item.trim()).filter(Boolean) } });
|
||||||
|
}
|
||||||
|
|
||||||
openMediaPicker(): void {
|
openMediaPicker(): void {
|
||||||
this.mediaPickerOpen = true;
|
this.mediaPickerOpen = true;
|
||||||
}
|
}
|
||||||
@@ -100,7 +156,23 @@ export class AdminProductFormComponent {
|
|||||||
this.productChange.emit({ media: { ...this.product.media, gallery: this.product.media.gallery.filter((_, i) => i !== index) } });
|
this.productChange.emit({ media: { ...this.product.media, gallery: this.product.media.gallery.filter((_, i) => i !== index) } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
moveGalleryImage(index: number, direction: -1 | 1): void {
|
||||||
|
const gallery = [...this.product.media.gallery];
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= gallery.length) return;
|
||||||
|
[gallery[index], gallery[target]] = [gallery[target], gallery[index]];
|
||||||
|
this.productChange.emit({ media: { ...this.product.media, gallery } });
|
||||||
|
}
|
||||||
|
|
||||||
finalPrice(): number {
|
finalPrice(): number {
|
||||||
return Math.round(this.product.price * (1 - this.product.discount / 100));
|
return Math.round(this.product.price * (1 - this.product.discount / 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stockStatusLabelKey(): string {
|
||||||
|
switch (this.product.stockStatus) {
|
||||||
|
case 'out_of_stock': return 'adminProducts.outOfStock';
|
||||||
|
case 'low_stock': return 'adminProducts.lowStock';
|
||||||
|
default: return 'adminProducts.inStock';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<section class="admin-products-card">
|
<section class="admin-products-card">
|
||||||
|
@if (dashboardStats) {
|
||||||
|
<app-products-dashboard [stats]="dashboardStats" (openProduct)="edit.emit($event)" />
|
||||||
|
}
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminProducts.search' | translate" />
|
<app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminProducts.search' | translate" />
|
||||||
@@ -29,13 +33,40 @@
|
|||||||
<label class="check"><input type="checkbox" [checked]="filters.includeArchived" (change)="filtersChange.emit({ includeArchived: $any($event.target).checked })" /><span>{{ 'adminProducts.showArchived' | translate }}</span></label>
|
<label class="check"><input type="checkbox" [checked]="filters.includeArchived" (change)="filtersChange.emit({ includeArchived: $any($event.target).checked })" /><span>{{ 'adminProducts.showArchived' | translate }}</span></label>
|
||||||
<label class="check"><input type="checkbox" [checked]="infiniteScroll" (change)="infiniteScrollToggle.emit($any($event.target).checked)" /><span>{{ 'adminProducts.infiniteScroll' | translate }}</span></label>
|
<label class="check"><input type="checkbox" [checked]="infiniteScroll" (change)="infiniteScrollToggle.emit($any($event.target).checked)" /><span>{{ 'adminProducts.infiniteScroll' | translate }}</span></label>
|
||||||
</div>
|
</div>
|
||||||
<app-button variant="primary" (click)="create.emit()">{{ 'adminProducts.create' | translate }}</app-button>
|
<div class="toolbar__view-controls">
|
||||||
|
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.viewMode' | translate">
|
||||||
|
<app-button variant="ghost" size="sm" [attr.aria-pressed]="viewMode === 'table'" (click)="viewModeChange.emit('table')">{{ 'adminProducts.viewTable' | translate }}</app-button>
|
||||||
|
<app-button variant="ghost" size="sm" [attr.aria-pressed]="viewMode === 'grid'" (click)="viewModeChange.emit('grid')">{{ 'adminProducts.viewGrid' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.density' | translate">
|
||||||
|
<app-button variant="ghost" size="sm" [attr.aria-pressed]="density === 'comfortable'" (click)="densityChange.emit('comfortable')">{{ 'adminProducts.densityComfortable' | translate }}</app-button>
|
||||||
|
<app-button variant="ghost" size="sm" [attr.aria-pressed]="density === 'compact'" (click)="densityChange.emit('compact')">{{ 'adminProducts.densityCompact' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
<app-button variant="ghost" size="sm" (click)="columnsPanelOpen.set(!columnsPanelOpen())">{{ 'adminProducts.columns' | translate }}</app-button>
|
||||||
|
<app-button variant="primary" (click)="create.emit()">{{ 'adminProducts.create' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (columnsPanelOpen()) {
|
||||||
|
<app-card padding="sm" class="columns-panel">
|
||||||
|
@for (column of allColumns; track column) {
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" [checked]="isColumnVisible(column)" (change)="columnToggle.emit({ column, visible: $any($event.target).checked })" />
|
||||||
|
<span>{{ ('adminProducts.column_' + column) | translate }}</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</app-card>
|
||||||
|
}
|
||||||
|
|
||||||
@if (selectedIds.length > 0) {
|
@if (selectedIds.length > 0) {
|
||||||
<div class="bulk-actions">
|
<div class="bulk-actions">
|
||||||
|
<span>{{ selectedIds.length }} {{ 'adminProducts.selectedCount' | translate }}</span>
|
||||||
<app-button variant="secondary" size="sm" (click)="bulkVisibility.emit(true)">{{ 'adminProducts.bulkShow' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="bulkVisibility.emit(true)">{{ 'adminProducts.bulkShow' | translate }}</app-button>
|
||||||
<app-button variant="secondary" size="sm" (click)="bulkVisibility.emit(false)">{{ 'adminProducts.bulkHide' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="bulkVisibility.emit(false)">{{ 'adminProducts.bulkHide' | translate }}</app-button>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="openAssignCategory()">{{ 'adminProducts.bulkAssignCategory' | translate }}</app-button>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="openAssignTags()">{{ 'adminProducts.bulkAssignTags' | translate }}</app-button>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="bulkDuplicate.emit()">{{ 'adminProducts.bulkDuplicateAction' | translate }}</app-button>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="bulkExport.emit()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
|
||||||
<app-button variant="danger" size="sm" (click)="bulkDelete.emit()">{{ 'adminProducts.bulkDelete' | translate }}</app-button>
|
<app-button variant="danger" size="sm" (click)="bulkDelete.emit()">{{ 'adminProducts.bulkDelete' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -47,18 +78,24 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else if (products.length === 0) {
|
} @else if (products.length === 0) {
|
||||||
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate" />
|
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate">
|
||||||
} @else {
|
<span slot="actions">
|
||||||
<app-table>
|
<app-button variant="primary" (click)="create.emit()">{{ 'adminProducts.create' | translate }}</app-button>
|
||||||
|
</span>
|
||||||
|
</app-empty-state>
|
||||||
|
<p class="admin-products-card__guide">{{ 'adminProducts.emptyGuide' | translate }}</p>
|
||||||
|
} @else if (viewMode === 'table') {
|
||||||
|
<app-table [class.density-compact]="density === 'compact'">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><input type="checkbox" (change)="selectAll.emit($any($event.target).checked)" /></th>
|
<th><input type="checkbox" (change)="selectAll.emit($any($event.target).checked)" /></th>
|
||||||
<th>{{ 'adminProducts.name' | translate }}</th>
|
<th>{{ 'adminProducts.name' | translate }}</th>
|
||||||
<th>{{ 'backoffice.sku' | translate }}</th>
|
@if (isColumnVisible('sku')) { <th>{{ 'backoffice.sku' | translate }}</th> }
|
||||||
<th>{{ 'adminProducts.brand' | translate }}</th>
|
@if (isColumnVisible('brand')) { <th>{{ 'adminProducts.brand' | translate }}</th> }
|
||||||
<th>{{ 'backoffice.price' | translate }}</th>
|
@if (isColumnVisible('price')) { <th>{{ 'backoffice.price' | translate }}</th> }
|
||||||
<th>{{ 'backoffice.status' | translate }}</th>
|
@if (isColumnVisible('stock')) { <th>{{ 'adminProducts.stockStatus' | translate }}</th> }
|
||||||
<th>{{ 'adminProducts.visibility' | translate }}</th>
|
@if (isColumnVisible('visibility')) { <th>{{ 'adminProducts.visibility' | translate }}</th> }
|
||||||
|
<th>{{ 'adminProducts.healthColumn' | translate }}</th>
|
||||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -67,15 +104,24 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><input type="checkbox" [checked]="isSelected(product.id)" (change)="selectionChange.emit({ id: product.id, checked: $any($event.target).checked })" /></td>
|
<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.name }}</td>
|
||||||
<td>{{ product.sku }}</td>
|
@if (isColumnVisible('sku')) { <td>{{ product.sku }}</td> }
|
||||||
<td>{{ product.brand }}</td>
|
@if (isColumnVisible('brand')) { <td>{{ product.brand }}</td> }
|
||||||
<td>{{ product.price }} {{ product.currency }}</td>
|
@if (isColumnVisible('price')) { <td>{{ product.price }} {{ product.currency }}</td> }
|
||||||
<td>{{ product.stockStatus }}</td>
|
@if (isColumnVisible('stock')) {
|
||||||
<td>
|
<td>
|
||||||
<app-badge [variant]="product.visible ? 'success' : 'neutral'">
|
<app-badge [variant]="product.stockStatus === 'out_of_stock' ? 'danger' : product.stockStatus === 'low_stock' ? 'warning' : 'success'">
|
||||||
{{ product.visible ? ('adminProducts.visible' | translate) : ('adminProducts.hidden' | translate) }}
|
{{ ('adminProducts.' + (product.stockStatus === 'out_of_stock' ? 'outOfStock' : product.stockStatus === 'low_stock' ? 'lowStock' : 'inStock')) | translate }}
|
||||||
</app-badge>
|
</app-badge>
|
||||||
</td>
|
</td>
|
||||||
|
}
|
||||||
|
@if (isColumnVisible('visibility')) {
|
||||||
|
<td>
|
||||||
|
<app-badge [variant]="product.visible ? 'success' : 'neutral'">
|
||||||
|
{{ product.visible ? ('adminProducts.visible' | translate) : ('adminProducts.hidden' | translate) }}
|
||||||
|
</app-badge>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td class="health-cell"><app-product-health-widget [items]="healthItems(product)" [completionPercent]="health(product).completionPercent" [compact]="true" /></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<app-button variant="secondary" size="sm" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</app-button>
|
||||||
<app-button variant="secondary" size="sm" (click)="duplicate.emit(product.id)">{{ 'adminProducts.duplicate' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="duplicate.emit(product.id)">{{ 'adminProducts.duplicate' | translate }}</app-button>
|
||||||
@@ -90,7 +136,35 @@
|
|||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</app-table>
|
</app-table>
|
||||||
|
} @else {
|
||||||
|
<div class="product-grid" [class.product-grid--compact]="density === 'compact'">
|
||||||
|
@for (product of products; track product.id) {
|
||||||
|
<app-card padding="sm" class="product-grid__item">
|
||||||
|
<label class="product-grid__select">
|
||||||
|
<input type="checkbox" [checked]="isSelected(product.id)" (change)="selectionChange.emit({ id: product.id, checked: $any($event.target).checked })" [attr.aria-label]="product.name" />
|
||||||
|
</label>
|
||||||
|
@if (product.media.images[0] || product.media.gallery[0]) {
|
||||||
|
<img class="product-grid__image" [src]="product.media.images[0] || product.media.gallery[0]" [alt]="product.name" loading="lazy" />
|
||||||
|
} @else {
|
||||||
|
<div class="product-grid__image product-grid__image--placeholder">{{ 'adminProducts.noImage' | translate }}</div>
|
||||||
|
}
|
||||||
|
<h4 class="product-grid__name">{{ product.name }}</h4>
|
||||||
|
<p class="product-grid__price">{{ product.price }} {{ product.currency }}</p>
|
||||||
|
<app-badge [variant]="product.visible ? 'success' : 'neutral'">
|
||||||
|
{{ product.visible ? ('adminProducts.visible' | translate) : ('adminProducts.hidden' | translate) }}
|
||||||
|
</app-badge>
|
||||||
|
<app-product-health-widget [items]="healthItems(product)" [completionPercent]="health(product).completionPercent" [compact]="true" />
|
||||||
|
<div class="product-grid__actions">
|
||||||
|
<app-button variant="secondary" size="sm" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</app-button>
|
||||||
|
<app-button variant="ghost" size="sm" (click)="duplicate.emit(product.id)">{{ 'adminProducts.duplicate' | translate }}</app-button>
|
||||||
|
<app-button variant="ghost" size="sm" (click)="delete.emit(product.id)">{{ 'adminProducts.delete' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (products.length > 0) {
|
||||||
<div class="pager">
|
<div class="pager">
|
||||||
<span>{{ products.length }} / {{ total }} {{ 'adminProducts.items' | translate }}</span>
|
<span>{{ products.length }} / {{ total }} {{ 'adminProducts.items' | translate }}</span>
|
||||||
@if (infiniteScroll) {
|
@if (infiniteScroll) {
|
||||||
@@ -102,4 +176,28 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<app-dialog [open]="assignCategoryOpen()" [titleText]="'adminProducts.bulkAssignCategory' | translate" size="sm" (closed)="assignCategoryOpen.set(false)">
|
||||||
|
<div class="dialog-body">
|
||||||
|
<select #categorySelect>
|
||||||
|
@for (category of categories; track category.id) {
|
||||||
|
<option [value]="category.id">{{ category.title }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<app-button variant="secondary" (click)="assignCategoryOpen.set(false)">{{ 'mediaLibrary.cancel' | translate }}</app-button>
|
||||||
|
<app-button variant="primary" (click)="confirmAssignCategory(categorySelect.value)">{{ 'mediaLibrary.confirm' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-dialog>
|
||||||
|
|
||||||
|
<app-dialog [open]="assignTagsOpen()" [titleText]="'adminProducts.bulkAssignTags' | translate" size="sm" (closed)="assignTagsOpen.set(false)">
|
||||||
|
<div class="dialog-body">
|
||||||
|
<app-input [ngModel]="tagsInput()" (ngModelChange)="tagsInput.set($event)" [placeholder]="'adminProducts.tagsPlaceholder' | translate" />
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<app-button variant="secondary" (click)="assignTagsOpen.set(false)">{{ 'mediaLibrary.cancel' | translate }}</app-button>
|
||||||
|
<app-button variant="primary" (click)="confirmAssignTags()">{{ 'mediaLibrary.confirm' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-dialog>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -7,3 +7,24 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
|
|||||||
@media (max-width: 960px) { .filters { grid-template-columns: repeat(2, minmax(140px, 1fr)); } }
|
@media (max-width: 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; } }
|
@media (max-width: 640px) { .filters { grid-template-columns: 1fr; } .actions { flex-direction: column; align-items: stretch; } }
|
||||||
.skeleton-rows { display: grid; gap: 8px; }
|
.skeleton-rows { display: grid; gap: 8px; }
|
||||||
|
|
||||||
|
.toolbar__view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
|
.view-toggle { display: flex; gap: 2px; }
|
||||||
|
.columns-panel { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.health-cell { min-width: 140px; }
|
||||||
|
.admin-products-card__guide { margin: 0; text-align: center; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
|
||||||
|
|
||||||
|
.density-compact td, .density-compact th { padding: 4px 8px; }
|
||||||
|
|
||||||
|
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||||
|
.product-grid--compact { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
||||||
|
.product-grid__item { position: relative; display: grid; gap: 6px; }
|
||||||
|
.product-grid__select { position: absolute; top: 8px; left: 8px; z-index: 1; }
|
||||||
|
.product-grid__image { width: 100%; height: 120px; object-fit: cover; border-radius: 8px; background: var(--surface-muted, #eef2f0); }
|
||||||
|
.product-grid__image--placeholder { display: flex; align-items: center; justify-content: center; font-size: 0.75rem; color: var(--text-tertiary, #9aa6a2); }
|
||||||
|
.product-grid__name { margin: 0; font-size: 0.85rem; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.product-grid__price { margin: 0; font-size: 0.8rem; color: var(--text-secondary, #5f6e6a); }
|
||||||
|
.product-grid__actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.dialog-body { display: grid; gap: 12px; }
|
||||||
|
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters } from '../models/admin-product.model';
|
import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters } from '../models/admin-product.model';
|
||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
@@ -9,11 +9,30 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
|
|||||||
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
|
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
|
||||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||||
|
import { CardComponent } from '../../../../shared/ui/card/card.component';
|
||||||
|
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||||
|
import { ProductHealthWidgetComponent, ProductHealthItem } from './product-health-widget/product-health-widget.component';
|
||||||
|
import { ProductsDashboardComponent } from './products-dashboard/products-dashboard.component';
|
||||||
|
import { AdminProductColumn, AdminProductHealth, AdminProductsDashboardStats, AdminProductsDensity, AdminProductsViewMode, ALL_PRODUCT_COLUMNS } from '../facade/admin-products.facade';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-products-list',
|
selector: 'app-admin-products-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, SkeletonComponent, EmptyStateComponent],
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
TranslatePipe,
|
||||||
|
ButtonComponent,
|
||||||
|
InputComponent,
|
||||||
|
BadgeComponent,
|
||||||
|
TableComponent,
|
||||||
|
PaginationComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
EmptyStateComponent,
|
||||||
|
CardComponent,
|
||||||
|
DialogComponent,
|
||||||
|
ProductHealthWidgetComponent,
|
||||||
|
ProductsDashboardComponent,
|
||||||
|
],
|
||||||
templateUrl: './admin-products-list.component.html',
|
templateUrl: './admin-products-list.component.html',
|
||||||
styleUrls: ['./admin-products-list.component.scss'],
|
styleUrls: ['./admin-products-list.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -26,6 +45,11 @@ export class AdminProductsListComponent {
|
|||||||
@Input() selectedIds: string[] = [];
|
@Input() selectedIds: string[] = [];
|
||||||
@Input() loading = false;
|
@Input() loading = false;
|
||||||
@Input() infiniteScroll = false;
|
@Input() infiniteScroll = false;
|
||||||
|
@Input() viewMode: AdminProductsViewMode = 'table';
|
||||||
|
@Input() density: AdminProductsDensity = 'comfortable';
|
||||||
|
@Input() visibleColumns: AdminProductColumn[] = [...ALL_PRODUCT_COLUMNS];
|
||||||
|
@Input() dashboardStats: AdminProductsDashboardStats | null = null;
|
||||||
|
@Input() health!: (product: AdminProduct) => AdminProductHealth;
|
||||||
|
|
||||||
@Output() filtersChange = new EventEmitter<Partial<AdminProductListFilters>>();
|
@Output() filtersChange = new EventEmitter<Partial<AdminProductListFilters>>();
|
||||||
@Output() create = new EventEmitter<void>();
|
@Output() create = new EventEmitter<void>();
|
||||||
@@ -36,10 +60,23 @@ export class AdminProductsListComponent {
|
|||||||
@Output() selectAll = new EventEmitter<boolean>();
|
@Output() selectAll = new EventEmitter<boolean>();
|
||||||
@Output() bulkVisibility = new EventEmitter<boolean>();
|
@Output() bulkVisibility = new EventEmitter<boolean>();
|
||||||
@Output() bulkDelete = new EventEmitter<void>();
|
@Output() bulkDelete = new EventEmitter<void>();
|
||||||
|
@Output() bulkDuplicate = new EventEmitter<void>();
|
||||||
|
@Output() bulkAssignCategory = new EventEmitter<string>();
|
||||||
|
@Output() bulkAssignTags = new EventEmitter<string[]>();
|
||||||
|
@Output() bulkExport = new EventEmitter<void>();
|
||||||
@Output() archive = new EventEmitter<string>();
|
@Output() archive = new EventEmitter<string>();
|
||||||
@Output() restore = new EventEmitter<string>();
|
@Output() restore = new EventEmitter<string>();
|
||||||
@Output() loadMore = new EventEmitter<void>();
|
@Output() loadMore = new EventEmitter<void>();
|
||||||
@Output() infiniteScrollToggle = new EventEmitter<boolean>();
|
@Output() infiniteScrollToggle = new EventEmitter<boolean>();
|
||||||
|
@Output() viewModeChange = new EventEmitter<AdminProductsViewMode>();
|
||||||
|
@Output() densityChange = new EventEmitter<AdminProductsDensity>();
|
||||||
|
@Output() columnToggle = new EventEmitter<{ column: AdminProductColumn; visible: boolean }>();
|
||||||
|
|
||||||
|
protected readonly allColumns = ALL_PRODUCT_COLUMNS;
|
||||||
|
protected readonly columnsPanelOpen = signal(false);
|
||||||
|
protected readonly assignCategoryOpen = signal(false);
|
||||||
|
protected readonly assignTagsOpen = signal(false);
|
||||||
|
protected readonly tagsInput = signal('');
|
||||||
|
|
||||||
isSelected(id: string): boolean {
|
isSelected(id: string): boolean {
|
||||||
return this.selectedIds.includes(id);
|
return this.selectedIds.includes(id);
|
||||||
@@ -48,4 +85,42 @@ export class AdminProductsListComponent {
|
|||||||
totalPages(): number {
|
totalPages(): number {
|
||||||
return Math.max(1, Math.ceil(this.total / this.filters.pageSize));
|
return Math.max(1, Math.ceil(this.total / this.filters.pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isColumnVisible(column: AdminProductColumn): boolean {
|
||||||
|
return this.visibleColumns.includes(column);
|
||||||
|
}
|
||||||
|
|
||||||
|
healthItems(product: AdminProduct): ProductHealthItem[] {
|
||||||
|
const health = this.health(product);
|
||||||
|
return [
|
||||||
|
{ labelKey: 'adminProducts.healthImages', done: health.hasImages },
|
||||||
|
{ labelKey: 'adminProducts.healthSeo', done: health.hasSeo },
|
||||||
|
{ labelKey: 'adminProducts.healthPrice', done: health.hasPrice },
|
||||||
|
{ labelKey: 'adminProducts.healthCategory', done: health.hasCategory },
|
||||||
|
{ labelKey: 'adminProducts.healthDescription', done: health.hasDescription },
|
||||||
|
{ labelKey: 'adminProducts.healthInventory', done: health.hasInventory },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
openAssignCategory(): void {
|
||||||
|
this.assignCategoryOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmAssignCategory(categoryId: string): void {
|
||||||
|
this.bulkAssignCategory.emit(categoryId);
|
||||||
|
this.assignCategoryOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
openAssignTags(): void {
|
||||||
|
this.tagsInput.set('');
|
||||||
|
this.assignTagsOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmAssignTags(): void {
|
||||||
|
const tags = this.tagsInput().split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
if (tags.length > 0) {
|
||||||
|
this.bulkAssignTags.emit(tags);
|
||||||
|
}
|
||||||
|
this.assignTagsOpen.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="product-health" [class.product-health--compact]="compact()">
|
||||||
|
<div class="product-health__bar" role="progressbar" [attr.aria-valuenow]="completionPercent()" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<div class="product-health__bar-fill" [style.width.%]="completionPercent()"></div>
|
||||||
|
</div>
|
||||||
|
<span class="product-health__percent">{{ completionPercent() }}%</span>
|
||||||
|
@if (!compact()) {
|
||||||
|
<ul class="product-health__list">
|
||||||
|
@for (item of items(); track item.labelKey) {
|
||||||
|
<li [class.product-health__list-item--done]="item.done">
|
||||||
|
<span aria-hidden="true">{{ item.done ? '✓' : '○' }}</span>
|
||||||
|
<span>{{ item.labelKey | translate }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.product-health {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-health__bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-muted, #eef2f0);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-health__bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--brand-primary, #1e8a6e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-health__percent {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary, #5f6e6a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-health__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary, #5f6e6a);
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-health__list-item--done {
|
||||||
|
color: var(--text-primary, #1e3c38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-health--compact {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
|
||||||
|
export interface ProductHealthItem {
|
||||||
|
labelKey: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reusable checklist + completion meter, fed real per-item booleans from AdminProductsFacade.health() - never invents its own data. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-health-widget',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TranslatePipe],
|
||||||
|
templateUrl: './product-health-widget.component.html',
|
||||||
|
styleUrl: './product-health-widget.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ProductHealthWidgetComponent {
|
||||||
|
readonly items = input.required<ProductHealthItem[]>();
|
||||||
|
readonly completionPercent = input.required<number>();
|
||||||
|
readonly compact = input(false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<section class="products-dashboard">
|
||||||
|
<div class="products-dashboard__metrics">
|
||||||
|
<app-dashboard-metric labelKey="adminProducts.totalProducts" [value]="stats().total.toString()" />
|
||||||
|
<app-dashboard-metric labelKey="adminProducts.publishedCount" [value]="stats().published.toString()" />
|
||||||
|
<app-dashboard-metric labelKey="adminProducts.draftsCount" [value]="stats().drafts.toString()" />
|
||||||
|
<app-dashboard-metric labelKey="adminProducts.outOfStockCount" [value]="stats().outOfStock.toString()" />
|
||||||
|
<app-dashboard-metric labelKey="adminProducts.hiddenCount" [value]="stats().hidden.toString()" />
|
||||||
|
<app-dashboard-metric labelKey="adminProducts.missingImagesCount" [value]="stats().missingImages.toString()" />
|
||||||
|
<app-dashboard-metric labelKey="adminProducts.missingSeoCount" [value]="stats().missingSeo.toString()" />
|
||||||
|
<app-dashboard-metric labelKey="adminProducts.lowQualityCount" [value]="stats().lowQuality.toString()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="products-dashboard__row">
|
||||||
|
<app-card padding="md" class="products-dashboard__recent">
|
||||||
|
<h4>{{ 'adminProducts.recentlyEdited' | translate }}</h4>
|
||||||
|
@if (stats().recentlyEdited.length === 0) {
|
||||||
|
<p class="products-dashboard__hint">{{ 'adminProducts.emptyDescription' | translate }}</p>
|
||||||
|
} @else {
|
||||||
|
<ul>
|
||||||
|
@for (product of stats().recentlyEdited; track product.id) {
|
||||||
|
<li>
|
||||||
|
<button type="button" (click)="openProduct.emit(product.id)">{{ product.name || product.sku }}</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</app-card>
|
||||||
|
|
||||||
|
<app-card padding="md" class="products-dashboard__recommend">
|
||||||
|
<h4>{{ 'adminProducts.recommendedNext' | translate }}</h4>
|
||||||
|
<p>{{ stats().recommendation.labelKey | translate }}</p>
|
||||||
|
@if (stats().recommendation.productId) {
|
||||||
|
<app-button variant="primary" size="sm" (click)="goRecommended()">{{ 'adminProducts.openAction' | translate }}</app-button>
|
||||||
|
}
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
.products-dashboard {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-dashboard__metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.products-dashboard__metrics {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.products-dashboard__metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-dashboard__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.products-dashboard__row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-dashboard__recent,
|
||||||
|
.products-dashboard__recommend {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary, #5f6e6a);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-dashboard__recent ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-dashboard__recent button {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--brand-primary, #1e8a6e);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
&:hover, &:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-dashboard__hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Output, input } from '@angular/core';
|
||||||
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
import { ButtonComponent } from '../../../../../shared/ui/button/button.component';
|
||||||
|
import { CardComponent } from '../../../../../shared/ui/card/card.component';
|
||||||
|
import { DashboardMetricComponent } from '../../../dashboard/components/dashboard-metric.component';
|
||||||
|
import { AdminProductsDashboardStats } from '../../facade/admin-products.facade';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-products-dashboard',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TranslatePipe, ButtonComponent, CardComponent, DashboardMetricComponent],
|
||||||
|
templateUrl: './products-dashboard.component.html',
|
||||||
|
styleUrl: './products-dashboard.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ProductsDashboardComponent {
|
||||||
|
readonly stats = input.required<AdminProductsDashboardStats>();
|
||||||
|
|
||||||
|
@Output() openProduct = new EventEmitter<string>();
|
||||||
|
|
||||||
|
goRecommended(): void {
|
||||||
|
const id = this.stats().recommendation.productId;
|
||||||
|
if (id) {
|
||||||
|
this.openProduct.emit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,69 @@ import { AdminProduct, AdminProductCategoryOption, AdminProductEditorMode, Admin
|
|||||||
import { AdminProductsFormFactory } from '../services/admin-products-form.factory';
|
import { AdminProductsFormFactory } from '../services/admin-products-form.factory';
|
||||||
import { AdminProductsLocalGateway } from '../services/admin-products-local.gateway';
|
import { AdminProductsLocalGateway } from '../services/admin-products-local.gateway';
|
||||||
import { ProjectEditorFacade } from '../../../project-editor/facade/project-editor.facade';
|
import { ProjectEditorFacade } from '../../../project-editor/facade/project-editor.facade';
|
||||||
|
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||||
|
|
||||||
|
export type AdminProductsViewMode = 'table' | 'grid';
|
||||||
|
export type AdminProductsDensity = 'comfortable' | 'compact';
|
||||||
|
|
||||||
|
export interface AdminProductHealth {
|
||||||
|
hasImages: boolean;
|
||||||
|
hasSeo: boolean;
|
||||||
|
hasPrice: boolean;
|
||||||
|
hasCategory: boolean;
|
||||||
|
hasDescription: boolean;
|
||||||
|
hasInventory: boolean;
|
||||||
|
completionPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProductsDashboardStats {
|
||||||
|
total: number;
|
||||||
|
published: number;
|
||||||
|
drafts: number;
|
||||||
|
outOfStock: number;
|
||||||
|
hidden: number;
|
||||||
|
missingImages: number;
|
||||||
|
missingSeo: number;
|
||||||
|
lowQuality: number;
|
||||||
|
recentlyEdited: AdminProduct[];
|
||||||
|
recommendation: { labelKey: string; productId?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_MODE_KEY = 'admin-products:view-mode';
|
||||||
|
const DENSITY_KEY = 'admin-products:density';
|
||||||
|
const COLUMNS_KEY = 'admin-products:visible-columns';
|
||||||
|
|
||||||
|
export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated'] as const;
|
||||||
|
export type AdminProductColumn = typeof ALL_PRODUCT_COLUMNS[number];
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminProductsFacade {
|
export class AdminProductsFacade {
|
||||||
private readonly gateway = inject(AdminProductsLocalGateway);
|
private readonly gateway = inject(AdminProductsLocalGateway);
|
||||||
private readonly formFactory = inject(AdminProductsFormFactory);
|
private readonly formFactory = inject(AdminProductsFormFactory);
|
||||||
private readonly projectEditor = inject(ProjectEditorFacade);
|
private readonly projectEditor = inject(ProjectEditorFacade);
|
||||||
|
private readonly localStorage = inject(LocalStorageService);
|
||||||
|
|
||||||
|
readonly viewMode = signal<AdminProductsViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminProductsViewMode) || 'table');
|
||||||
|
readonly density = signal<AdminProductsDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminProductsDensity) || 'comfortable');
|
||||||
|
readonly visibleColumns = signal<AdminProductColumn[]>(this.localStorage.getJSON<AdminProductColumn[]>(COLUMNS_KEY) ?? [...ALL_PRODUCT_COLUMNS]);
|
||||||
|
|
||||||
|
setViewMode(mode: AdminProductsViewMode): void {
|
||||||
|
this.viewMode.set(mode);
|
||||||
|
this.localStorage.setItem(VIEW_MODE_KEY, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDensity(density: AdminProductsDensity): void {
|
||||||
|
this.density.set(density);
|
||||||
|
this.localStorage.setItem(DENSITY_KEY, density);
|
||||||
|
}
|
||||||
|
|
||||||
|
setColumnVisible(column: AdminProductColumn, visible: boolean): void {
|
||||||
|
const next = visible
|
||||||
|
? [...new Set([...this.visibleColumns(), column])]
|
||||||
|
: this.visibleColumns().filter(c => c !== column);
|
||||||
|
this.visibleColumns.set(next);
|
||||||
|
this.localStorage.setJSON(COLUMNS_KEY, next);
|
||||||
|
}
|
||||||
|
|
||||||
readonly supportedLocales = computed(() => this.projectEditor.bootstrap()?.localization.supportedLocales ?? ['en']);
|
readonly supportedLocales = computed(() => this.projectEditor.bootstrap()?.localization.supportedLocales ?? ['en']);
|
||||||
|
|
||||||
@@ -25,7 +82,7 @@ export class AdminProductsFacade {
|
|||||||
visibility: 'all',
|
visibility: 'all',
|
||||||
stock: 'all',
|
stock: 'all',
|
||||||
includeArchived: false,
|
includeArchived: false,
|
||||||
sort: 'title',
|
sort: (this.localStorage.getItem('admin-products:sort') as AdminProductListFilters['sort']) || 'title',
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
@@ -78,11 +135,11 @@ export class AdminProductsFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
archiveOne(id: string): void {
|
archiveOne(id: string): void {
|
||||||
this.gateway.archiveProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
|
this.gateway.archiveProduct(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } });
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreOne(id: string): void {
|
restoreOne(id: string): void {
|
||||||
this.gateway.restoreProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
|
this.gateway.restoreProduct(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } });
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCategories(): void {
|
loadCategories(): void {
|
||||||
@@ -90,6 +147,9 @@ export class AdminProductsFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateFilters(patch: Partial<AdminProductListFilters>): void {
|
updateFilters(patch: Partial<AdminProductListFilters>): void {
|
||||||
|
if (patch.sort) {
|
||||||
|
this.localStorage.setItem('admin-products:sort', patch.sort);
|
||||||
|
}
|
||||||
this.filters.update(current => ({ ...current, ...patch, page: patch.page ?? 1 }));
|
this.filters.update(current => ({ ...current, ...patch, page: patch.page ?? 1 }));
|
||||||
this.loadList();
|
this.loadList();
|
||||||
}
|
}
|
||||||
@@ -108,6 +168,7 @@ export class AdminProductsFacade {
|
|||||||
updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe());
|
updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe());
|
||||||
this.selectedIds.set([]);
|
this.selectedIds.set([]);
|
||||||
this.loadList();
|
this.loadList();
|
||||||
|
this.loadDashboardStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
applyBulkDelete(): void {
|
applyBulkDelete(): void {
|
||||||
@@ -115,6 +176,108 @@ export class AdminProductsFacade {
|
|||||||
ids.forEach(id => this.gateway.deleteProduct(id).pipe(take(1)).subscribe());
|
ids.forEach(id => this.gateway.deleteProduct(id).pipe(take(1)).subscribe());
|
||||||
this.selectedIds.set([]);
|
this.selectedIds.set([]);
|
||||||
this.loadList();
|
this.loadList();
|
||||||
|
this.loadDashboardStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBulkAssignCategory(categoryId: string): void {
|
||||||
|
const selected = new Set(this.selectedIds());
|
||||||
|
const updates = this.products().filter(product => selected.has(product.id)).map(product => ({ ...product, categoryId, updatedAt: new Date().toISOString() }));
|
||||||
|
updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe());
|
||||||
|
this.selectedIds.set([]);
|
||||||
|
this.loadList();
|
||||||
|
this.loadDashboardStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBulkAssignTags(tags: string[]): void {
|
||||||
|
const selected = new Set(this.selectedIds());
|
||||||
|
const updates = this.products().filter(product => selected.has(product.id)).map(product => ({
|
||||||
|
...product,
|
||||||
|
badges: [...new Set([...product.badges, ...tags])],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe());
|
||||||
|
this.selectedIds.set([]);
|
||||||
|
this.loadList();
|
||||||
|
this.loadDashboardStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBulkDuplicate(): void {
|
||||||
|
const ids = [...this.selectedIds()];
|
||||||
|
ids.forEach(id => this.gateway.duplicateProduct(id).pipe(take(1)).subscribe());
|
||||||
|
this.selectedIds.set([]);
|
||||||
|
this.loadList();
|
||||||
|
this.loadDashboardStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-side CSV of the currently selected rows - no backend export endpoint exists, so this never claims a server-generated file. */
|
||||||
|
exportSelectedAsCsv(): void {
|
||||||
|
const selected = new Set(this.selectedIds());
|
||||||
|
const rows = this.products().filter(product => selected.has(product.id));
|
||||||
|
const header = ['id', 'name', 'sku', 'brand', 'price', 'currency', 'quantity', 'stockStatus', 'visible', 'categoryId'];
|
||||||
|
const lines = rows.map(product => [product.id, product.name, product.sku, product.brand, product.price, product.currency, product.quantity, product.stockStatus, product.visible, product.categoryId]
|
||||||
|
.map(value => `"${String(value).replace(/"/g, '""')}"`).join(','));
|
||||||
|
const csv = [header.join(','), ...lines].join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'products-export.csv';
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
health(product: AdminProduct): AdminProductHealth {
|
||||||
|
const hasImages = product.media.images.length > 0 || product.media.gallery.length > 0;
|
||||||
|
const hasSeo = !!(product.seo.metaTitle.trim() && product.seo.metaDescription.trim());
|
||||||
|
const hasPrice = product.price > 0;
|
||||||
|
const hasCategory = !!product.categoryId;
|
||||||
|
const hasDescription = product.shortDescription.trim().length > 0;
|
||||||
|
const hasInventory = product.quantity > 0 || product.availability.trim().length > 0;
|
||||||
|
const checks = [hasImages, hasSeo, hasPrice, hasCategory, hasDescription, hasInventory];
|
||||||
|
const completionPercent = Math.round((checks.filter(Boolean).length / checks.length) * 100);
|
||||||
|
return { hasImages, hasSeo, hasPrice, hasCategory, hasDescription, hasInventory, completionPercent };
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly dashboardStats = signal<AdminProductsDashboardStats | null>(null);
|
||||||
|
|
||||||
|
/** Dashboard stats must reflect the whole catalog, not just the current page - loads with a page size large enough to cover the mock catalog in one call. */
|
||||||
|
loadDashboardStats(): void {
|
||||||
|
this.gateway.loadProducts({ ...this.filters(), page: 1, pageSize: 100000, includeArchived: true, search: '', categoryId: null, visibility: 'all', stock: 'all' })
|
||||||
|
.pipe(take(1))
|
||||||
|
.subscribe({ next: result => this.dashboardStats.set(this.computeDashboardStats(result.items)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeDashboardStats(products: AdminProduct[]): AdminProductsDashboardStats {
|
||||||
|
const withHealth = products.map(product => ({ product, health: this.health(product) }));
|
||||||
|
|
||||||
|
const missingImages = withHealth.filter(entry => !entry.health.hasImages);
|
||||||
|
const missingSeo = withHealth.filter(entry => !entry.health.hasSeo);
|
||||||
|
const lowQuality = withHealth.filter(entry => entry.health.completionPercent < 50);
|
||||||
|
const recentlyEdited = [...products].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 5);
|
||||||
|
|
||||||
|
let recommendation: AdminProductsDashboardStats['recommendation'];
|
||||||
|
if (missingImages[0]) {
|
||||||
|
recommendation = { labelKey: 'adminProducts.recommendAddImages', productId: missingImages[0].product.id };
|
||||||
|
} else if (missingSeo[0]) {
|
||||||
|
recommendation = { labelKey: 'adminProducts.recommendAddSeo', productId: missingSeo[0].product.id };
|
||||||
|
} else if (products.some(p => p.stockStatus === 'out_of_stock')) {
|
||||||
|
recommendation = { labelKey: 'adminProducts.recommendRestock', productId: products.find(p => p.stockStatus === 'out_of_stock')?.id };
|
||||||
|
} else {
|
||||||
|
recommendation = { labelKey: 'adminProducts.recommendNone' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: products.length,
|
||||||
|
published: products.filter(p => p.visible && !p.archived).length,
|
||||||
|
drafts: products.filter(p => !p.visible && !p.archived).length,
|
||||||
|
outOfStock: products.filter(p => p.stockStatus === 'out_of_stock').length,
|
||||||
|
hidden: products.filter(p => !p.visible).length,
|
||||||
|
missingImages: missingImages.length,
|
||||||
|
missingSeo: missingSeo.length,
|
||||||
|
lowQuality: lowQuality.length,
|
||||||
|
recentlyEdited,
|
||||||
|
recommendation,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
startCreate(): void {
|
startCreate(): void {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service';
|
|||||||
selector: 'app-admin-product-editor-page',
|
selector: 'app-admin-product-editor-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [AdminProductFormComponent, TranslatePipe],
|
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()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
|
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()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (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; }`],
|
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
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import { LanguageService } from '../../../../services/language.service';
|
|||||||
[selectedIds]="facade.selectedIds()"
|
[selectedIds]="facade.selectedIds()"
|
||||||
[loading]="facade.loading()"
|
[loading]="facade.loading()"
|
||||||
[infiniteScroll]="facade.infiniteScroll()"
|
[infiniteScroll]="facade.infiniteScroll()"
|
||||||
|
[viewMode]="facade.viewMode()"
|
||||||
|
[density]="facade.density()"
|
||||||
|
[visibleColumns]="facade.visibleColumns()"
|
||||||
|
[dashboardStats]="facade.dashboardStats()"
|
||||||
|
[health]="healthFn"
|
||||||
(filtersChange)="facade.updateFilters($event)"
|
(filtersChange)="facade.updateFilters($event)"
|
||||||
(create)="create()"
|
(create)="create()"
|
||||||
(edit)="edit($event)"
|
(edit)="edit($event)"
|
||||||
@@ -28,7 +33,14 @@ import { LanguageService } from '../../../../services/language.service';
|
|||||||
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
|
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
|
||||||
(selectAll)="facade.toggleAll($event)"
|
(selectAll)="facade.toggleAll($event)"
|
||||||
(bulkVisibility)="facade.applyBulkVisibility($event)"
|
(bulkVisibility)="facade.applyBulkVisibility($event)"
|
||||||
(bulkDelete)="facade.applyBulkDelete()" />`,
|
(bulkDelete)="facade.applyBulkDelete()"
|
||||||
|
(bulkDuplicate)="facade.applyBulkDuplicate()"
|
||||||
|
(bulkAssignCategory)="facade.applyBulkAssignCategory($event)"
|
||||||
|
(bulkAssignTags)="facade.applyBulkAssignTags($event)"
|
||||||
|
(bulkExport)="facade.exportSelectedAsCsv()"
|
||||||
|
(viewModeChange)="facade.setViewMode($event)"
|
||||||
|
(densityChange)="facade.setDensity($event)"
|
||||||
|
(columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class AdminProductsListPageComponent {
|
export class AdminProductsListPageComponent {
|
||||||
@@ -36,9 +48,12 @@ export class AdminProductsListPageComponent {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
|
readonly healthFn = (product: Parameters<AdminProductsFacade['health']>[0]) => this.facade.health(product);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.facade.loadCategories();
|
this.facade.loadCategories();
|
||||||
this.facade.loadList();
|
this.facade.loadList();
|
||||||
|
this.facade.loadDashboardStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void { this.facade.startCreate(); void this.router.navigate([this.lang(), 'backoffice', 'products', 'create']); }
|
create(): void { this.facade.startCreate(); void this.router.navigate([this.lang(), 'backoffice', 'products', 'create']); }
|
||||||
|
|||||||
@@ -1225,6 +1225,141 @@ export const en: Translations = {
|
|||||||
adminProducts: {
|
adminProducts: {
|
||||||
emptyTitle: 'No products found',
|
emptyTitle: 'No products found',
|
||||||
emptyDescription: 'Try adjusting your filters, or create a new product.',
|
emptyDescription: 'Try adjusting your filters, or create a new product.',
|
||||||
|
emptyGuide: 'Good products have a clear title, at least one photo, a price, and a short description — that\'s enough to publish. You can always add more detail later.',
|
||||||
|
search: 'Search products…',
|
||||||
|
category: 'Category',
|
||||||
|
allCategories: 'All categories',
|
||||||
|
visibility: 'Visibility',
|
||||||
|
allVisibility: 'All visibility',
|
||||||
|
visible: 'Visible',
|
||||||
|
hidden: 'Hidden',
|
||||||
|
stockStatus: 'Stock status',
|
||||||
|
allStock: 'All stock levels',
|
||||||
|
inStock: 'In stock',
|
||||||
|
lowStock: 'Low stock',
|
||||||
|
outOfStock: 'Out of stock',
|
||||||
|
sortTitle: 'Name',
|
||||||
|
sortPrice: 'Price',
|
||||||
|
sortPriority: 'Priority',
|
||||||
|
sortStock: 'Stock',
|
||||||
|
sortUpdated: 'Last updated',
|
||||||
|
showArchived: 'Show archived',
|
||||||
|
infiniteScroll: 'Infinite scroll',
|
||||||
|
viewMode: 'View',
|
||||||
|
viewTable: 'Table',
|
||||||
|
viewGrid: 'Grid',
|
||||||
|
density: 'Density',
|
||||||
|
densityComfortable: 'Comfortable',
|
||||||
|
densityCompact: 'Compact',
|
||||||
|
columns: 'Columns',
|
||||||
|
column_sku: 'SKU',
|
||||||
|
column_brand: 'Brand',
|
||||||
|
column_price: 'Price',
|
||||||
|
column_stock: 'Stock',
|
||||||
|
column_visibility: 'Visibility',
|
||||||
|
column_updated: 'Last updated',
|
||||||
|
create: 'Create product',
|
||||||
|
edit: 'Edit product',
|
||||||
|
duplicate: 'Duplicate',
|
||||||
|
selectedCount: 'selected',
|
||||||
|
bulkShow: 'Publish',
|
||||||
|
bulkHide: 'Hide',
|
||||||
|
bulkAssignCategory: 'Assign category',
|
||||||
|
bulkAssignTags: 'Assign tags',
|
||||||
|
bulkDuplicateAction: 'Duplicate',
|
||||||
|
bulkExportAction: 'Export',
|
||||||
|
bulkDelete: 'Delete',
|
||||||
|
tagsPlaceholder: 'e.g. summer, sale',
|
||||||
|
name: 'Name',
|
||||||
|
brand: 'Brand',
|
||||||
|
healthColumn: 'Health',
|
||||||
|
actions: 'Actions',
|
||||||
|
archive: 'Archive',
|
||||||
|
delete: 'Delete',
|
||||||
|
items: 'products',
|
||||||
|
loadMore: 'Load more',
|
||||||
|
noImage: 'No image',
|
||||||
|
slug: 'URL slug',
|
||||||
|
barcode: 'Barcode',
|
||||||
|
priority: 'Priority',
|
||||||
|
shortDescription: 'Short description',
|
||||||
|
translations: 'Translations',
|
||||||
|
primaryImage: 'Primary image',
|
||||||
|
primaryImageHint: 'The main photo shown in listings and search results.',
|
||||||
|
gallery: 'Gallery',
|
||||||
|
galleryHint: 'Additional photos shown on the product page. Drag order with the arrows.',
|
||||||
|
moveLeft: 'Move earlier',
|
||||||
|
moveRight: 'Move later',
|
||||||
|
videos: 'Videos (one URL per line)',
|
||||||
|
discount: 'Discount %',
|
||||||
|
currency: 'Currency',
|
||||||
|
stockQuantity: 'Stock quantity',
|
||||||
|
stockQuantityHint: 'How many units are available to sell right now.',
|
||||||
|
availabilityStatus: 'Availability',
|
||||||
|
availabilityStatusHint: 'What customers see: in stock, running low, or out of stock.',
|
||||||
|
availabilityNote: 'Availability note',
|
||||||
|
availabilityNoteHint: 'Optional extra detail shown to customers, e.g. "Ships in 3 days".',
|
||||||
|
reservedNote: 'Reserved stock isn\'t tracked separately yet — quantity shown here is total available stock.',
|
||||||
|
relatedProducts: 'Related products',
|
||||||
|
specifications: 'Specifications',
|
||||||
|
attributes: 'Attributes',
|
||||||
|
addRow: 'Add row',
|
||||||
|
removeRow: 'Remove',
|
||||||
|
rowKey: 'Name',
|
||||||
|
rowValue: 'Value',
|
||||||
|
addVariant: 'Add variant',
|
||||||
|
variantName: 'Variant name',
|
||||||
|
variantsHint: 'Each variant is a purchasable option of this product, e.g. a size or color, with its own price and stock.',
|
||||||
|
seoExplain: 'This is what shows up in search results — a clear title and description help customers find this product.',
|
||||||
|
searchTitle: 'Search title',
|
||||||
|
searchTitleHint: 'The headline shown in search results. Keep it short and descriptive.',
|
||||||
|
searchDescription: 'Search description',
|
||||||
|
searchDescriptionHint: 'A short summary shown under the title in search results.',
|
||||||
|
seoMissingTitle: 'Add a search title so this product can be found.',
|
||||||
|
seoMissingDescription: 'Add a search description to improve how this product appears in results.',
|
||||||
|
keywords: 'Keywords',
|
||||||
|
featured: 'Featured',
|
||||||
|
recommended: 'Recommended',
|
||||||
|
new: 'New',
|
||||||
|
bestseller: 'Bestseller',
|
||||||
|
badges: 'Badges (comma-separated)',
|
||||||
|
htmlDescription: 'Detailed description',
|
||||||
|
customer: 'Customer feedback',
|
||||||
|
reviewsReadonly: 'Reviews',
|
||||||
|
noReviews: 'No reviews yet.',
|
||||||
|
questionsReadonly: 'Questions',
|
||||||
|
noQuestions: 'No questions yet.',
|
||||||
|
save: 'Save product',
|
||||||
|
groupGeneral: 'General',
|
||||||
|
groupMedia: 'Media',
|
||||||
|
groupPricing: 'Pricing',
|
||||||
|
groupInventory: 'Inventory',
|
||||||
|
groupCategories: 'Categories',
|
||||||
|
groupAttributes: 'Attributes',
|
||||||
|
groupSeo: 'SEO',
|
||||||
|
groupVisibility: 'Visibility',
|
||||||
|
groupAdvanced: 'Advanced',
|
||||||
|
healthImages: 'Images',
|
||||||
|
healthSeo: 'SEO',
|
||||||
|
healthPrice: 'Price',
|
||||||
|
healthCategory: 'Category',
|
||||||
|
healthDescription: 'Description',
|
||||||
|
healthInventory: 'Inventory',
|
||||||
|
totalProducts: 'Total products',
|
||||||
|
publishedCount: 'Published',
|
||||||
|
draftsCount: 'Drafts',
|
||||||
|
outOfStockCount: 'Out of stock',
|
||||||
|
hiddenCount: 'Hidden',
|
||||||
|
missingImagesCount: 'Missing images',
|
||||||
|
missingSeoCount: 'Missing SEO',
|
||||||
|
lowQualityCount: 'Low-quality',
|
||||||
|
recentlyEdited: 'Recently edited',
|
||||||
|
recommendedNext: 'Recommended next step',
|
||||||
|
openAction: 'Open',
|
||||||
|
recommendAddImages: 'A product has no photos — add at least one image so it looks trustworthy.',
|
||||||
|
recommendAddSeo: 'A product is missing a search title or description — add one so it can be found.',
|
||||||
|
recommendRestock: 'A product is out of stock — restock it or mark it unavailable.',
|
||||||
|
recommendNone: 'Nice work — your catalog is in good shape.',
|
||||||
},
|
},
|
||||||
adminUsers: {
|
adminUsers: {
|
||||||
emptyTitle: 'No users found',
|
emptyTitle: 'No users found',
|
||||||
|
|||||||
@@ -1220,6 +1220,141 @@ export const hy: Translations = {
|
|||||||
adminProducts: {
|
adminProducts: {
|
||||||
emptyTitle: 'Ապրանքներ չեն գտնվել',
|
emptyTitle: 'Ապրանքներ չեն գտնվել',
|
||||||
emptyDescription: 'Փոխեք ֆիլտրերը կամ ստեղծեք նոր ապրանք։',
|
emptyDescription: 'Փոխեք ֆիլտրերը կամ ստեղծեք նոր ապրանք։',
|
||||||
|
emptyGuide: 'Լավ ապրանքին բավական է հստակ վերնագիր, առնվազն մեկ լուսանկար, գին և կարճ նկարագրություն՝ հրապարակելու համար։ Մնացածը կարող եք ավելացնել հետո։',
|
||||||
|
search: 'Փնտրել ապրանքներ…',
|
||||||
|
category: 'Կատեգորիա',
|
||||||
|
allCategories: 'Բոլոր կատեգորիաները',
|
||||||
|
visibility: 'Տեսանելիություն',
|
||||||
|
allVisibility: 'Ցանկացած տեսանելիություն',
|
||||||
|
visible: 'Տեսանելի',
|
||||||
|
hidden: 'Թաքցված',
|
||||||
|
stockStatus: 'Պահեստի կարգավիճակ',
|
||||||
|
allStock: 'Ցանկացած մնացորդ',
|
||||||
|
inStock: 'Առկա է',
|
||||||
|
lowStock: 'Քիչ է մնացել',
|
||||||
|
outOfStock: 'Առկա չէ',
|
||||||
|
sortTitle: 'Անուն',
|
||||||
|
sortPrice: 'Գին',
|
||||||
|
sortPriority: 'Առաջնահերթություն',
|
||||||
|
sortStock: 'Մնացորդ',
|
||||||
|
sortUpdated: 'Վերջին փոփոխություն',
|
||||||
|
showArchived: 'Ցույց տալ արխիվայինները',
|
||||||
|
infiniteScroll: 'Անվերջ ոլորում',
|
||||||
|
viewMode: 'Տեսք',
|
||||||
|
viewTable: 'Աղյուսակ',
|
||||||
|
viewGrid: 'Ցանց',
|
||||||
|
density: 'Խտություն',
|
||||||
|
densityComfortable: 'Հարմարավետ',
|
||||||
|
densityCompact: 'Կոմպակտ',
|
||||||
|
columns: 'Սյունակներ',
|
||||||
|
column_sku: 'SKU',
|
||||||
|
column_brand: 'Ապրանքանիշ',
|
||||||
|
column_price: 'Գին',
|
||||||
|
column_stock: 'Մնացորդ',
|
||||||
|
column_visibility: 'Տեսանելիություն',
|
||||||
|
column_updated: 'Վերջին փոփոխություն',
|
||||||
|
create: 'Ստեղծել ապրանք',
|
||||||
|
edit: 'Խմբագրել ապրանքը',
|
||||||
|
duplicate: 'Կրկնօրինակել',
|
||||||
|
selectedCount: 'ընտրված է',
|
||||||
|
bulkShow: 'Հրապարակել',
|
||||||
|
bulkHide: 'Թաքցնել',
|
||||||
|
bulkAssignCategory: 'Նշանակել կատեգորիա',
|
||||||
|
bulkAssignTags: 'Նշանակել պիտակներ',
|
||||||
|
bulkDuplicateAction: 'Կրկնօրինակել',
|
||||||
|
bulkExportAction: 'Արտահանել',
|
||||||
|
bulkDelete: 'Ջնջել',
|
||||||
|
tagsPlaceholder: 'օր․՝ ամառ, զեղչ',
|
||||||
|
name: 'Անուն',
|
||||||
|
brand: 'Ապրանքանիշ',
|
||||||
|
healthColumn: 'Որակ',
|
||||||
|
actions: 'Գործողություններ',
|
||||||
|
archive: 'Արխիվացնել',
|
||||||
|
delete: 'Ջնջել',
|
||||||
|
items: 'ապրանք',
|
||||||
|
loadMore: 'Բեռնել ավելին',
|
||||||
|
noImage: 'Առանց պատկերի',
|
||||||
|
slug: 'URL հասցե',
|
||||||
|
barcode: 'Շտրիխկոդ',
|
||||||
|
priority: 'Առաջնահերթություն',
|
||||||
|
shortDescription: 'Կարճ նկարագրություն',
|
||||||
|
translations: 'Թարգմանություններ',
|
||||||
|
primaryImage: 'Հիմնական պատկեր',
|
||||||
|
primaryImageHint: 'Հիմնական լուսանկարը, որ երևում է ցանկերում և որոնման արդյունքներում։',
|
||||||
|
gallery: 'Պատկերասրահ',
|
||||||
|
galleryHint: 'Լրացուցիչ լուսանկարներ ապրանքի էջում։ Կարգը փոխեք սլաքներով։',
|
||||||
|
moveLeft: 'Տեղափոխել ավելի վաղ',
|
||||||
|
moveRight: 'Տեղափոխել ավելի ուշ',
|
||||||
|
videos: 'Տեսանյութեր (մեկ հղում մեկ տողում)',
|
||||||
|
discount: 'Զեղչ %',
|
||||||
|
currency: 'Արժույթ',
|
||||||
|
stockQuantity: 'Պահեստի քանակ',
|
||||||
|
stockQuantityHint: 'Քանի հատ է հասանելի վաճառքի համար հենց հիմա։',
|
||||||
|
availabilityStatus: 'Հասանելիություն',
|
||||||
|
availabilityStatusHint: 'Ինչ է տեսնում հաճախորդը՝ առկա է, քիչ է մնացել, թե առկա չէ։',
|
||||||
|
availabilityNote: 'Հասանելիության նշում',
|
||||||
|
availabilityNoteHint: 'Լրացուցիչ մանրամասն հաճախորդի համար, օր․՝ «Առաքում 3 օրում»։',
|
||||||
|
reservedNote: 'Ամրագրված պաշարը դեռ առանձին չի հաշվառվում․ այստեղ ցուցադրվում է ընդհանուր հասանելի քանակը։',
|
||||||
|
relatedProducts: 'Առնչվող ապրանքներ',
|
||||||
|
specifications: 'Բնութագրեր',
|
||||||
|
attributes: 'Հատկանիշներ',
|
||||||
|
addRow: 'Ավելացնել տող',
|
||||||
|
removeRow: 'Հեռացնել',
|
||||||
|
rowKey: 'Անուն',
|
||||||
|
rowValue: 'Արժեք',
|
||||||
|
addVariant: 'Ավելացնել տարբերակ',
|
||||||
|
variantName: 'Տարբերակի անուն',
|
||||||
|
variantsHint: 'Յուրաքանչյուր տարբերակ այս ապրանքի առանձին գնվող տարբերակ է (օր․՝ չափս կամ գույն)՝ իր գնով և մնացորդով։',
|
||||||
|
seoExplain: 'Սա այն է, ինչ երևում է որոնման արդյունքներում․ հստակ վերնագիրն ու նկարագրությունը օգնում են գտնել այս ապրանքը։',
|
||||||
|
searchTitle: 'Որոնման վերնագիր',
|
||||||
|
searchTitleHint: 'Վերնագիրը, որ երևում է որոնման արդյունքներում։ Պահեք կարճ։',
|
||||||
|
searchDescription: 'Որոնման նկարագրություն',
|
||||||
|
searchDescriptionHint: 'Կարճ նկարագրություն՝ վերնագրի տակ, որոնման արդյունքներում։',
|
||||||
|
seoMissingTitle: 'Ավելացրեք որոնման վերնագիր, որպեսզի ապրանքը գտնվի։',
|
||||||
|
seoMissingDescription: 'Ավելացրեք որոնման նկարագրություն՝ ապրանքի ցուցադրումը բարելավելու համար։',
|
||||||
|
keywords: 'Հիմնաբառեր',
|
||||||
|
featured: 'Ընտրված',
|
||||||
|
recommended: 'Երաշխավորված',
|
||||||
|
new: 'Նոր',
|
||||||
|
bestseller: 'Բեսթսելլեր',
|
||||||
|
badges: 'Կրծքանշաններ (ստորակետերով)',
|
||||||
|
htmlDescription: 'Մանրամասն նկարագրություն',
|
||||||
|
customer: 'Հաճախորդների արձագանք',
|
||||||
|
reviewsReadonly: 'Կարծիքներ',
|
||||||
|
noReviews: 'Կարծիքներ դեռ չկան։',
|
||||||
|
questionsReadonly: 'Հարցեր',
|
||||||
|
noQuestions: 'Հարցեր դեռ չկան։',
|
||||||
|
save: 'Պահպանել ապրանքը',
|
||||||
|
groupGeneral: 'Հիմնական',
|
||||||
|
groupMedia: 'Մեդիա',
|
||||||
|
groupPricing: 'Գին',
|
||||||
|
groupInventory: 'Պահեստ',
|
||||||
|
groupCategories: 'Կատեգորիաներ',
|
||||||
|
groupAttributes: 'Հատկանիշներ',
|
||||||
|
groupSeo: 'SEO',
|
||||||
|
groupVisibility: 'Տեսանելիություն',
|
||||||
|
groupAdvanced: 'Լրացուցիչ',
|
||||||
|
healthImages: 'Պատկերներ',
|
||||||
|
healthSeo: 'SEO',
|
||||||
|
healthPrice: 'Գին',
|
||||||
|
healthCategory: 'Կատեգորիա',
|
||||||
|
healthDescription: 'Նկարագրություն',
|
||||||
|
healthInventory: 'Պահեստ',
|
||||||
|
totalProducts: 'Ընդհանուր ապրանքներ',
|
||||||
|
publishedCount: 'Հրապարակված',
|
||||||
|
draftsCount: 'Սևագրեր',
|
||||||
|
outOfStockCount: 'Առկա չէ',
|
||||||
|
hiddenCount: 'Թաքցված',
|
||||||
|
missingImagesCount: 'Առանց պատկերի',
|
||||||
|
missingSeoCount: 'Առանց SEO-ի',
|
||||||
|
lowQualityCount: 'Ցածր որակի',
|
||||||
|
recentlyEdited: 'Վերջերս խմբագրված',
|
||||||
|
recommendedNext: 'Հաջորդ առաջարկվող քայլը',
|
||||||
|
openAction: 'Բացել',
|
||||||
|
recommendAddImages: 'Ապրանքը լուսանկար չունի․ ավելացրեք առնվազն մեկ պատկեր։',
|
||||||
|
recommendAddSeo: 'Ապրանքը չունի որոնման վերնագիր կամ նկարագրություն․ ավելացրեք դրանք։',
|
||||||
|
recommendRestock: 'Ապրանքը սպառված է․ համալրեք պաշարը կամ թաքցրեք այն։',
|
||||||
|
recommendNone: 'Հիանալի է․ կատալոգը լավ վիճակում է։',
|
||||||
},
|
},
|
||||||
adminUsers: {
|
adminUsers: {
|
||||||
emptyTitle: 'Օգտատերեր չեն գտնվել',
|
emptyTitle: 'Օգտատերեր չեն գտնվել',
|
||||||
|
|||||||
@@ -1220,6 +1220,141 @@ export const ru: Translations = {
|
|||||||
adminProducts: {
|
adminProducts: {
|
||||||
emptyTitle: 'Товары не найдены',
|
emptyTitle: 'Товары не найдены',
|
||||||
emptyDescription: 'Измените фильтры или создайте новый товар.',
|
emptyDescription: 'Измените фильтры или создайте новый товар.',
|
||||||
|
emptyGuide: 'Хорошему товару нужны понятное название, минимум одно фото, цена и короткое описание — этого достаточно для публикации. Остальное можно добавить позже.',
|
||||||
|
search: 'Поиск товаров…',
|
||||||
|
category: 'Категория',
|
||||||
|
allCategories: 'Все категории',
|
||||||
|
visibility: 'Видимость',
|
||||||
|
allVisibility: 'Любая видимость',
|
||||||
|
visible: 'Видимый',
|
||||||
|
hidden: 'Скрытый',
|
||||||
|
stockStatus: 'Статус наличия',
|
||||||
|
allStock: 'Любой остаток',
|
||||||
|
inStock: 'В наличии',
|
||||||
|
lowStock: 'Мало на складе',
|
||||||
|
outOfStock: 'Нет в наличии',
|
||||||
|
sortTitle: 'Имя',
|
||||||
|
sortPrice: 'Цена',
|
||||||
|
sortPriority: 'Приоритет',
|
||||||
|
sortStock: 'Остаток',
|
||||||
|
sortUpdated: 'Последнее изменение',
|
||||||
|
showArchived: 'Показать архивные',
|
||||||
|
infiniteScroll: 'Бесконечная прокрутка',
|
||||||
|
viewMode: 'Вид',
|
||||||
|
viewTable: 'Таблица',
|
||||||
|
viewGrid: 'Сетка',
|
||||||
|
density: 'Плотность',
|
||||||
|
densityComfortable: 'Комфортная',
|
||||||
|
densityCompact: 'Компактная',
|
||||||
|
columns: 'Столбцы',
|
||||||
|
column_sku: 'Артикул',
|
||||||
|
column_brand: 'Бренд',
|
||||||
|
column_price: 'Цена',
|
||||||
|
column_stock: 'Остаток',
|
||||||
|
column_visibility: 'Видимость',
|
||||||
|
column_updated: 'Последнее изменение',
|
||||||
|
create: 'Создать товар',
|
||||||
|
edit: 'Редактировать товар',
|
||||||
|
duplicate: 'Дублировать',
|
||||||
|
selectedCount: 'выбрано',
|
||||||
|
bulkShow: 'Опубликовать',
|
||||||
|
bulkHide: 'Скрыть',
|
||||||
|
bulkAssignCategory: 'Назначить категорию',
|
||||||
|
bulkAssignTags: 'Назначить теги',
|
||||||
|
bulkDuplicateAction: 'Дублировать',
|
||||||
|
bulkExportAction: 'Экспорт',
|
||||||
|
bulkDelete: 'Удалить',
|
||||||
|
tagsPlaceholder: 'например, лето, скидка',
|
||||||
|
name: 'Название',
|
||||||
|
brand: 'Бренд',
|
||||||
|
healthColumn: 'Качество',
|
||||||
|
actions: 'Действия',
|
||||||
|
archive: 'В архив',
|
||||||
|
delete: 'Удалить',
|
||||||
|
items: 'товаров',
|
||||||
|
loadMore: 'Загрузить ещё',
|
||||||
|
noImage: 'Нет изображения',
|
||||||
|
slug: 'URL-адрес',
|
||||||
|
barcode: 'Штрихкод',
|
||||||
|
priority: 'Приоритет',
|
||||||
|
shortDescription: 'Краткое описание',
|
||||||
|
translations: 'Переводы',
|
||||||
|
primaryImage: 'Главное изображение',
|
||||||
|
primaryImageHint: 'Основное фото, показываемое в списках и результатах поиска.',
|
||||||
|
gallery: 'Галерея',
|
||||||
|
galleryHint: 'Дополнительные фото на странице товара. Порядок меняется стрелками.',
|
||||||
|
moveLeft: 'Переместить раньше',
|
||||||
|
moveRight: 'Переместить позже',
|
||||||
|
videos: 'Видео (по одной ссылке на строку)',
|
||||||
|
discount: 'Скидка %',
|
||||||
|
currency: 'Валюта',
|
||||||
|
stockQuantity: 'Количество на складе',
|
||||||
|
stockQuantityHint: 'Сколько единиц доступно для продажи прямо сейчас.',
|
||||||
|
availabilityStatus: 'Доступность',
|
||||||
|
availabilityStatusHint: 'Что видит покупатель: в наличии, заканчивается или нет в наличии.',
|
||||||
|
availabilityNote: 'Примечание о доступности',
|
||||||
|
availabilityNoteHint: 'Дополнительная информация для покупателя, например «Доставка за 3 дня».',
|
||||||
|
reservedNote: 'Резерв пока не учитывается отдельно — здесь показано общее доступное количество.',
|
||||||
|
relatedProducts: 'Похожие товары',
|
||||||
|
specifications: 'Характеристики',
|
||||||
|
attributes: 'Атрибуты',
|
||||||
|
addRow: 'Добавить строку',
|
||||||
|
removeRow: 'Удалить',
|
||||||
|
rowKey: 'Название',
|
||||||
|
rowValue: 'Значение',
|
||||||
|
addVariant: 'Добавить вариант',
|
||||||
|
variantName: 'Название варианта',
|
||||||
|
variantsHint: 'Каждый вариант — это отдельный покупаемый вариант товара (например, размер или цвет) со своей ценой и остатком.',
|
||||||
|
seoExplain: 'Это то, что видно в результатах поиска — понятные заголовок и описание помогают найти товар.',
|
||||||
|
searchTitle: 'Заголовок для поиска',
|
||||||
|
searchTitleHint: 'Заголовок, показываемый в результатах поиска. Делайте его коротким.',
|
||||||
|
searchDescription: 'Описание для поиска',
|
||||||
|
searchDescriptionHint: 'Краткое описание под заголовком в результатах поиска.',
|
||||||
|
seoMissingTitle: 'Добавьте заголовок для поиска, чтобы товар можно было найти.',
|
||||||
|
seoMissingDescription: 'Добавьте описание для поиска, чтобы улучшить отображение товара.',
|
||||||
|
keywords: 'Ключевые слова',
|
||||||
|
featured: 'Рекомендуемый',
|
||||||
|
recommended: 'В подборке',
|
||||||
|
new: 'Новинка',
|
||||||
|
bestseller: 'Хит продаж',
|
||||||
|
badges: 'Значки (через запятую)',
|
||||||
|
htmlDescription: 'Подробное описание',
|
||||||
|
customer: 'Отзывы покупателей',
|
||||||
|
reviewsReadonly: 'Отзывы',
|
||||||
|
noReviews: 'Пока нет отзывов.',
|
||||||
|
questionsReadonly: 'Вопросы',
|
||||||
|
noQuestions: 'Пока нет вопросов.',
|
||||||
|
save: 'Сохранить товар',
|
||||||
|
groupGeneral: 'Основное',
|
||||||
|
groupMedia: 'Медиа',
|
||||||
|
groupPricing: 'Цена',
|
||||||
|
groupInventory: 'Склад',
|
||||||
|
groupCategories: 'Категории',
|
||||||
|
groupAttributes: 'Атрибуты',
|
||||||
|
groupSeo: 'SEO',
|
||||||
|
groupVisibility: 'Видимость',
|
||||||
|
groupAdvanced: 'Дополнительно',
|
||||||
|
healthImages: 'Изображения',
|
||||||
|
healthSeo: 'SEO',
|
||||||
|
healthPrice: 'Цена',
|
||||||
|
healthCategory: 'Категория',
|
||||||
|
healthDescription: 'Описание',
|
||||||
|
healthInventory: 'Склад',
|
||||||
|
totalProducts: 'Всего товаров',
|
||||||
|
publishedCount: 'Опубликовано',
|
||||||
|
draftsCount: 'Черновики',
|
||||||
|
outOfStockCount: 'Нет в наличии',
|
||||||
|
hiddenCount: 'Скрыто',
|
||||||
|
missingImagesCount: 'Без изображений',
|
||||||
|
missingSeoCount: 'Без SEO',
|
||||||
|
lowQualityCount: 'Низкое качество',
|
||||||
|
recentlyEdited: 'Недавно изменённые',
|
||||||
|
recommendedNext: 'Рекомендуемый следующий шаг',
|
||||||
|
openAction: 'Открыть',
|
||||||
|
recommendAddImages: 'У товара нет фото — добавьте хотя бы одно изображение.',
|
||||||
|
recommendAddSeo: 'У товара нет заголовка или описания для поиска — добавьте их.',
|
||||||
|
recommendRestock: 'Товар закончился на складе — пополните запас или скройте его.',
|
||||||
|
recommendNone: 'Отлично — каталог в хорошем состоянии.',
|
||||||
},
|
},
|
||||||
adminUsers: {
|
adminUsers: {
|
||||||
emptyTitle: 'Пользователи не найдены',
|
emptyTitle: 'Пользователи не найдены',
|
||||||
|
|||||||
@@ -1232,6 +1232,141 @@ export interface Translations {
|
|||||||
adminProducts: {
|
adminProducts: {
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
|
emptyGuide: string;
|
||||||
|
search: string;
|
||||||
|
category: string;
|
||||||
|
allCategories: string;
|
||||||
|
visibility: string;
|
||||||
|
allVisibility: string;
|
||||||
|
visible: string;
|
||||||
|
hidden: string;
|
||||||
|
stockStatus: string;
|
||||||
|
allStock: string;
|
||||||
|
inStock: string;
|
||||||
|
lowStock: string;
|
||||||
|
outOfStock: string;
|
||||||
|
sortTitle: string;
|
||||||
|
sortPrice: string;
|
||||||
|
sortPriority: string;
|
||||||
|
sortStock: string;
|
||||||
|
sortUpdated: string;
|
||||||
|
showArchived: string;
|
||||||
|
infiniteScroll: string;
|
||||||
|
viewMode: string;
|
||||||
|
viewTable: string;
|
||||||
|
viewGrid: string;
|
||||||
|
density: string;
|
||||||
|
densityComfortable: string;
|
||||||
|
densityCompact: string;
|
||||||
|
columns: string;
|
||||||
|
column_sku: string;
|
||||||
|
column_brand: string;
|
||||||
|
column_price: string;
|
||||||
|
column_stock: string;
|
||||||
|
column_visibility: string;
|
||||||
|
column_updated: string;
|
||||||
|
create: string;
|
||||||
|
edit: string;
|
||||||
|
duplicate: string;
|
||||||
|
selectedCount: string;
|
||||||
|
bulkShow: string;
|
||||||
|
bulkHide: string;
|
||||||
|
bulkAssignCategory: string;
|
||||||
|
bulkAssignTags: string;
|
||||||
|
bulkDuplicateAction: string;
|
||||||
|
bulkExportAction: string;
|
||||||
|
bulkDelete: string;
|
||||||
|
tagsPlaceholder: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
healthColumn: string;
|
||||||
|
actions: string;
|
||||||
|
archive: string;
|
||||||
|
delete: string;
|
||||||
|
items: string;
|
||||||
|
loadMore: string;
|
||||||
|
noImage: string;
|
||||||
|
slug: string;
|
||||||
|
barcode: string;
|
||||||
|
priority: string;
|
||||||
|
shortDescription: string;
|
||||||
|
translations: string;
|
||||||
|
primaryImage: string;
|
||||||
|
primaryImageHint: string;
|
||||||
|
gallery: string;
|
||||||
|
galleryHint: string;
|
||||||
|
moveLeft: string;
|
||||||
|
moveRight: string;
|
||||||
|
videos: string;
|
||||||
|
discount: string;
|
||||||
|
currency: string;
|
||||||
|
stockQuantity: string;
|
||||||
|
stockQuantityHint: string;
|
||||||
|
availabilityStatus: string;
|
||||||
|
availabilityStatusHint: string;
|
||||||
|
availabilityNote: string;
|
||||||
|
availabilityNoteHint: string;
|
||||||
|
reservedNote: string;
|
||||||
|
relatedProducts: string;
|
||||||
|
specifications: string;
|
||||||
|
attributes: string;
|
||||||
|
addRow: string;
|
||||||
|
removeRow: string;
|
||||||
|
rowKey: string;
|
||||||
|
rowValue: string;
|
||||||
|
addVariant: string;
|
||||||
|
variantName: string;
|
||||||
|
variantsHint: string;
|
||||||
|
seoExplain: string;
|
||||||
|
searchTitle: string;
|
||||||
|
searchTitleHint: string;
|
||||||
|
searchDescription: string;
|
||||||
|
searchDescriptionHint: string;
|
||||||
|
seoMissingTitle: string;
|
||||||
|
seoMissingDescription: string;
|
||||||
|
keywords: string;
|
||||||
|
featured: string;
|
||||||
|
recommended: string;
|
||||||
|
new: string;
|
||||||
|
bestseller: string;
|
||||||
|
badges: string;
|
||||||
|
htmlDescription: string;
|
||||||
|
customer: string;
|
||||||
|
reviewsReadonly: string;
|
||||||
|
noReviews: string;
|
||||||
|
questionsReadonly: string;
|
||||||
|
noQuestions: string;
|
||||||
|
save: string;
|
||||||
|
groupGeneral: string;
|
||||||
|
groupMedia: string;
|
||||||
|
groupPricing: string;
|
||||||
|
groupInventory: string;
|
||||||
|
groupCategories: string;
|
||||||
|
groupAttributes: string;
|
||||||
|
groupSeo: string;
|
||||||
|
groupVisibility: string;
|
||||||
|
groupAdvanced: string;
|
||||||
|
healthImages: string;
|
||||||
|
healthSeo: string;
|
||||||
|
healthPrice: string;
|
||||||
|
healthCategory: string;
|
||||||
|
healthDescription: string;
|
||||||
|
healthInventory: string;
|
||||||
|
totalProducts: string;
|
||||||
|
publishedCount: string;
|
||||||
|
draftsCount: string;
|
||||||
|
outOfStockCount: string;
|
||||||
|
hiddenCount: string;
|
||||||
|
missingImagesCount: string;
|
||||||
|
missingSeoCount: string;
|
||||||
|
lowQualityCount: string;
|
||||||
|
recentlyEdited: string;
|
||||||
|
recommendedNext: string;
|
||||||
|
openAction: string;
|
||||||
|
recommendAddImages: string;
|
||||||
|
recommendAddSeo: string;
|
||||||
|
recommendRestock: string;
|
||||||
|
recommendNone: string;
|
||||||
};
|
};
|
||||||
adminUsers: {
|
adminUsers: {
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user