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:
sdarbinyan
2026-07-18 16:34:06 +04:00
parent 5d52d81c7d
commit aeb48504c5
19 changed files with 1561 additions and 212 deletions

View File

@@ -1,4 +1,27 @@
<section class="form-card">
<div class="form-card__header">
<app-product-health-widget [items]="healthItems()" [completionPercent]="health.completionPercent" />
</div>
<div class="form-card__tabs" role="tablist">
@for (group of groups; track group.id) {
<button
type="button"
role="tab"
[attr.aria-selected]="activeGroup() === group.id"
[class.form-card__tab--active]="activeGroup() === group.id"
class="form-card__tab"
(click)="setGroup(group.id)"
>
{{ group.labelKey | translate }}
@if (group.id === 'media' && !health.hasImages) { <app-badge variant="warning">!</app-badge> }
@if (group.id === 'seo' && !health.hasSeo) { <app-badge variant="warning">!</app-badge> }
</button>
}
</div>
<div class="form-card__panel">
@if (activeGroup() === 'general') {
<div class="grid two">
<app-form-field [label]="'adminProducts.name' | translate">
<app-input [ngModel]="product.name" (ngModelChange)="updateField('name', $event)" />
@@ -15,30 +38,55 @@
<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>
<app-form-field [label]="'adminProducts.shortDescription' | translate">
<app-input [ngModel]="product.shortDescription" (ngModelChange)="updateField('shortDescription', $event)" />
</app-form-field>
<h3>{{ 'adminProducts.media' | translate }}</h3>
<h4 class="form-card__subheading">{{ 'adminProducts.translations' | translate }}</h4>
@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>
</div>
}
}
@if (activeGroup() === 'media') {
<div class="grid one">
<label><span>{{ 'adminProducts.images' | translate }}</span><textarea rows="3" [ngModel]="product.media.images.join('\n')" (ngModelChange)="updateList('images', $event)"></textarea></label>
<label><span>{{ 'adminProducts.gallery' | translate }}</span>
<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="" /><button type="button" (click)="removeGalleryImage($index)">×</button></div>
<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">&larr;</button>
<button type="button" (click)="moveGalleryImage($index, 1)" [attr.aria-label]="'adminProducts.moveRight' | translate">&rarr;</button>
<button type="button" (click)="removeGalleryImage($index)" [attr.aria-label]="'mediaLibrary.delete' | translate">&times;</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" />
</label>
<label><span>{{ 'adminProducts.videos' | translate }}</span><textarea rows="3" [ngModel]="product.media.videos.join('\n')" (ngModelChange)="updateList('videos', $event)"></textarea></label>
</div>
</app-form-field>
<h3>{{ 'adminProducts.pricing' | translate }}</h3>
<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)" />
@@ -50,91 +98,158 @@
<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>
}
<h3>{{ 'adminProducts.inventory' | translate }}</h3>
@if (activeGroup() === 'inventory') {
<div class="grid three">
<app-form-field [label]="'adminProducts.quantity' | translate">
<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>
<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-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>
<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>
<p class="form-card__explain">{{ 'adminProducts.reservedNote' | translate }}</p>
}
<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 } })" />
@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>
<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>
<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>
}
<h3>{{ 'adminProducts.preview' | translate }}</h3>
<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() === '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>
<h3>{{ 'adminProducts.customer' | translate }}</h3>
<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>
<h4>{{ 'adminProducts.reviewsReadonly' | translate }}</h4>
<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>
<h4>{{ 'adminProducts.questionsReadonly' | translate }}</h4>
<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 class="actions"><app-button variant="primary" (click)="save.emit()">{{ 'adminProducts.save' | translate }}</app-button></div>
</section>

View File

@@ -11,36 +11,24 @@ input[type='checkbox'] { width: auto; }
.sub-block { border-top: 1px dashed #d9e2e1; padding-top: 12px; }
.readonly-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.readonly-grid article { border: 1px solid #ececec; border-radius: 12px; padding: 12px; }
.gallery-grid { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.gallery-item { position: relative; width: 64px; height: 64px; }
.gallery-item img { width: 100%; height: 100%; object-fit: cover; border-radius: 8px; border: 1px solid var(--border-color, #d3dad9); }
.gallery-item button {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
border-radius: 50%;
.gallery-grid { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start; }
.gallery-item { display: grid; gap: 4px; width: 72px; }
.gallery-item img { width: 72px; height: 72px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border-color, #d3dad9); }
.gallery-item__actions { display: flex; justify-content: space-between; gap: 2px; }
.gallery-item__actions button {
flex: 1;
min-width: 0;
border: none;
background: #d9433c;
color: #fff;
border-radius: 6px;
background: var(--surface-muted, #eef2f0);
color: var(--text-secondary, #5f6e6a);
cursor: pointer;
transition: transform var(--transition-fast, 120ms ease), background-color var(--transition-fast, 120ms ease);
font-size: 0.75rem;
padding: 2px 4px;
&::before {
content: '';
position: absolute;
inset: -12px;
}
&:hover {
background: #b8352f;
transform: scale(1.1);
}
&:focus-visible {
outline: 2px solid #d9433c;
outline-offset: 2px;
&:hover, &:focus-visible {
background: var(--brand-primary, #1e8a6e);
color: #fff;
}
}
.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; }
@media (max-width: 700px) { .related-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; }

View File

@@ -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 { 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 { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { InputComponent } from '../../../../shared/ui/input/input.component';
import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field.component';
import { 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 { 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({
selector: 'app-admin-product-form',
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',
styleUrls: ['./admin-product-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -22,12 +44,40 @@ export class AdminProductFormComponent {
@Input() allProducts: AdminProduct[] = [];
@Input() locales: string[] = ['en'];
@Input() mode: 'create' | 'edit' | 'duplicate' = 'create';
protected mediaPickerOpen = false;
@Input() health!: AdminProductHealth;
@Output() productChange = new EventEmitter<Partial<AdminProduct>>();
@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 {
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 {
if (type === 'badges') {
updateBadges(value: string): void {
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;
readonly createSpecRow = () => ({ key: '', value: '' });
readonly createAttributeRow = () => ({ key: '', value: '' });
readonly createVariantRow = (): AdminProductVariant => ({ name: '', price: 0, quantity: 0 });
updateSpecifications(rows: AdminProductSpecification[]): void {
this.productChange.emit({ specifications: rows });
}
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>);
updateSpecificationField(index: number, field: 'key' | 'value', value: string): void {
this.updateSpecifications(this.product.specifications.map((row, i) => i === index ? { ...row, [field]: value } : row));
}
joinKeyValue(items: Array<{ key: string; value: string }>): string {
return items.map(item => `${item.key}|${item.value}`).join('\n');
updateAttributes(rows: AdminProductAttribute[]): void {
this.productChange.emit({ attributes: rows });
}
updateVariants(value: string): void {
const variants: AdminProductVariant[] = value.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
const [name, price, quantity] = line.split('|');
return { name: name?.trim() ?? '', price: Number(price?.trim() ?? 0) || 0, quantity: Number(quantity?.trim() ?? 0) || 0 };
});
this.productChange.emit({ variants });
updateAttributeField(index: number, field: 'key' | 'value', value: string): void {
this.updateAttributes(this.product.attributes.map((row, i) => i === index ? { ...row, [field]: value } : row));
}
joinVariants(variants: AdminProductVariant[]): string {
return variants.map(variant => `${variant.name}|${variant.price}|${variant.quantity}`).join('\n');
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 {
@@ -87,6 +135,14 @@ export class AdminProductFormComponent {
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 {
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) } });
}
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 {
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';
}
}
}

View File

@@ -1,4 +1,8 @@
<section class="admin-products-card">
@if (dashboardStats) {
<app-products-dashboard [stats]="dashboardStats" (openProduct)="edit.emit($event)" />
}
<div class="toolbar">
<div class="filters">
<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]="infiniteScroll" (change)="infiniteScrollToggle.emit($any($event.target).checked)" /><span>{{ 'adminProducts.infiniteScroll' | translate }}</span></label>
</div>
<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>
@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) {
<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(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>
</div>
}
@@ -47,18 +78,24 @@
}
</div>
} @else if (products.length === 0) {
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate" />
} @else {
<app-table>
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate">
<span slot="actions">
<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>
<tr>
<th><input type="checkbox" (change)="selectAll.emit($any($event.target).checked)" /></th>
<th>{{ 'adminProducts.name' | translate }}</th>
<th>{{ 'backoffice.sku' | translate }}</th>
<th>{{ 'adminProducts.brand' | translate }}</th>
<th>{{ 'backoffice.price' | translate }}</th>
<th>{{ 'backoffice.status' | translate }}</th>
<th>{{ 'adminProducts.visibility' | translate }}</th>
@if (isColumnVisible('sku')) { <th>{{ 'backoffice.sku' | translate }}</th> }
@if (isColumnVisible('brand')) { <th>{{ 'adminProducts.brand' | translate }}</th> }
@if (isColumnVisible('price')) { <th>{{ 'backoffice.price' | translate }}</th> }
@if (isColumnVisible('stock')) { <th>{{ 'adminProducts.stockStatus' | translate }}</th> }
@if (isColumnVisible('visibility')) { <th>{{ 'adminProducts.visibility' | translate }}</th> }
<th>{{ 'adminProducts.healthColumn' | translate }}</th>
<th>{{ 'adminProducts.actions' | translate }}</th>
</tr>
</thead>
@@ -67,15 +104,24 @@
<tr>
<td><input type="checkbox" [checked]="isSelected(product.id)" (change)="selectionChange.emit({ id: product.id, checked: $any($event.target).checked })" /></td>
<td>{{ product.name }}</td>
<td>{{ product.sku }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.price }} {{ product.currency }}</td>
<td>{{ product.stockStatus }}</td>
@if (isColumnVisible('sku')) { <td>{{ product.sku }}</td> }
@if (isColumnVisible('brand')) { <td>{{ product.brand }}</td> }
@if (isColumnVisible('price')) { <td>{{ product.price }} {{ product.currency }}</td> }
@if (isColumnVisible('stock')) {
<td>
<app-badge [variant]="product.stockStatus === 'out_of_stock' ? 'danger' : product.stockStatus === 'low_stock' ? 'warning' : 'success'">
{{ ('adminProducts.' + (product.stockStatus === 'out_of_stock' ? 'outOfStock' : product.stockStatus === 'low_stock' ? 'lowStock' : 'inStock')) | translate }}
</app-badge>
</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">
<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>
@@ -90,7 +136,35 @@
}
</tbody>
</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">
<span>{{ products.length }} / {{ total }} {{ 'adminProducts.items' | translate }}</span>
@if (infiniteScroll) {
@@ -102,4 +176,28 @@
}
</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>

View File

@@ -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: 640px) { .filters { grid-template-columns: 1fr; } .actions { flex-direction: column; align-items: stretch; } }
.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; }

View File

@@ -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 { AdminProduct, AdminProductCategoryOption, AdminProductListFilters } from '../models/admin-product.model';
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 { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.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({
selector: 'app-admin-products-list',
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',
styleUrls: ['./admin-products-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -26,6 +45,11 @@ export class AdminProductsListComponent {
@Input() selectedIds: string[] = [];
@Input() loading = 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() create = new EventEmitter<void>();
@@ -36,10 +60,23 @@ export class AdminProductsListComponent {
@Output() selectAll = new EventEmitter<boolean>();
@Output() bulkVisibility = new EventEmitter<boolean>();
@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() restore = new EventEmitter<string>();
@Output() loadMore = new EventEmitter<void>();
@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 {
return this.selectedIds.includes(id);
@@ -48,4 +85,42 @@ export class AdminProductsListComponent {
totalPages(): number {
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,69 @@ import { AdminProduct, AdminProductCategoryOption, AdminProductEditorMode, Admin
import { AdminProductsFormFactory } from '../services/admin-products-form.factory';
import { AdminProductsLocalGateway } from '../services/admin-products-local.gateway';
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' })
export class AdminProductsFacade {
private readonly gateway = inject(AdminProductsLocalGateway);
private readonly formFactory = inject(AdminProductsFormFactory);
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']);
@@ -25,7 +82,7 @@ export class AdminProductsFacade {
visibility: 'all',
stock: 'all',
includeArchived: false,
sort: 'title',
sort: (this.localStorage.getItem('admin-products:sort') as AdminProductListFilters['sort']) || 'title',
page: 1,
pageSize: 10,
});
@@ -78,11 +135,11 @@ export class AdminProductsFacade {
}
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 {
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 {
@@ -90,6 +147,9 @@ export class AdminProductsFacade {
}
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.loadList();
}
@@ -108,6 +168,7 @@ export class AdminProductsFacade {
updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe());
this.selectedIds.set([]);
this.loadList();
this.loadDashboardStats();
}
applyBulkDelete(): void {
@@ -115,6 +176,108 @@ export class AdminProductsFacade {
ids.forEach(id => this.gateway.deleteProduct(id).pipe(take(1)).subscribe());
this.selectedIds.set([]);
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 {

View File

@@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service';
selector: 'app-admin-product-editor-page',
standalone: true,
imports: [AdminProductFormComponent, TranslatePipe],
template: `@if (facade.draft(); as draft) {<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; }`],
changeDetection: ChangeDetectionStrategy.OnPush
})

View File

@@ -16,6 +16,11 @@ import { LanguageService } from '../../../../services/language.service';
[selectedIds]="facade.selectedIds()"
[loading]="facade.loading()"
[infiniteScroll]="facade.infiniteScroll()"
[viewMode]="facade.viewMode()"
[density]="facade.density()"
[visibleColumns]="facade.visibleColumns()"
[dashboardStats]="facade.dashboardStats()"
[health]="healthFn"
(filtersChange)="facade.updateFilters($event)"
(create)="create()"
(edit)="edit($event)"
@@ -28,7 +33,14 @@ import { LanguageService } from '../../../../services/language.service';
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
(selectAll)="facade.toggleAll($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
})
export class AdminProductsListPageComponent {
@@ -36,9 +48,12 @@ export class AdminProductsListPageComponent {
private readonly router = inject(Router);
private readonly languageService = inject(LanguageService);
readonly healthFn = (product: Parameters<AdminProductsFacade['health']>[0]) => this.facade.health(product);
constructor() {
this.facade.loadCategories();
this.facade.loadList();
this.facade.loadDashboardStats();
}
create(): void { this.facade.startCreate(); void this.router.navigate([this.lang(), 'backoffice', 'products', 'create']); }

View File

@@ -1225,6 +1225,141 @@ export const en: Translations = {
adminProducts: {
emptyTitle: 'No products found',
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: {
emptyTitle: 'No users found',

View File

@@ -1220,6 +1220,141 @@ export const hy: Translations = {
adminProducts: {
emptyTitle: 'Ապրանքներ չեն գտնվել',
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: {
emptyTitle: 'Օգտատերեր չեն գտնվել',

View File

@@ -1220,6 +1220,141 @@ export const ru: Translations = {
adminProducts: {
emptyTitle: 'Товары не найдены',
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: {
emptyTitle: 'Пользователи не найдены',

View File

@@ -1232,6 +1232,141 @@ export interface Translations {
adminProducts: {
emptyTitle: 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: {
emptyTitle: string;