feat(admin): category editor auto-slug, SEO fill, breadcrumb and icon explainers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> P0 user feedback: category icon input and breadcrumbs ('Хлебные крошки') were unexplained, slug and SEO were manual. - Title edits derive the slug and SEO title with the same never-overwrite-manual-input rule as products (verified in browser) - SEO group gains the same 'Fill from product details' one-click action - Icon field now explains itself (emoji/symbol shown in menus) and shows a live preview of the entered symbol - Breadcrumb block explains in merchant language what customers see (Home › Electronics › Phones) and where it comes from; separator changed to › to match the storefront - New adminCategories keys (navBreadcrumbHint, iconHint) in en/ru/hy
This commit is contained in:
@@ -27,9 +27,9 @@
|
||||
}
|
||||
<div class="grid two">
|
||||
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
|
||||
<app-input [ngModel]="category.title" (ngModelChange)="updateField('title', $event)" />
|
||||
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
|
||||
</app-form-field>
|
||||
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [error]="slugTaken ? ('adminCategories.slugTaken' | translate) : null">
|
||||
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [hint]="'adminProducts.slugHint' | translate" [error]="slugTaken ? ('adminCategories.slugTaken' | translate) : null">
|
||||
<app-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" />
|
||||
</app-form-field>
|
||||
<label><span>{{ 'adminCategories.parent' | translate }}</span>
|
||||
@@ -40,8 +40,13 @@
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<app-form-field [label]="'adminCategories.icon' | translate">
|
||||
<app-input [ngModel]="category.icon" (ngModelChange)="updateField('icon', $event)" />
|
||||
<app-form-field [label]="'adminCategories.icon' | translate" [hint]="'adminCategories.iconHint' | translate">
|
||||
<div class="icon-field">
|
||||
<app-input [ngModel]="category.icon" (ngModelChange)="updateField('icon', $event)" />
|
||||
@if (category.icon) {
|
||||
<span class="icon-field__preview" aria-hidden="true">{{ category.icon }}</span>
|
||||
}
|
||||
</div>
|
||||
</app-form-field>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +78,10 @@
|
||||
|
||||
@if (activeGroup() === 'seo') {
|
||||
<p class="form-card__explain">{{ 'adminProducts.seoExplain' | translate }}</p>
|
||||
<div class="seo-generate-row">
|
||||
<app-button variant="secondary" size="sm" (click)="generateSeo()">{{ 'adminProducts.seoGenerate' | translate }}</app-button>
|
||||
<span class="seo-generate-row__hint">{{ 'adminProducts.seoGenerateHint' | translate }}</span>
|
||||
</div>
|
||||
<app-form-field [label]="'adminProducts.searchTitle' | translate" [hint]="'adminProducts.searchTitleHint' | translate" [error]="!category.seo.metaTitle.trim() ? ('adminProducts.seoMissingTitle' | translate) : null">
|
||||
<app-input [ngModel]="category.seo.metaTitle" (ngModelChange)="categoryChange.emit({ seo: { ...category.seo, metaTitle: $event } })" />
|
||||
</app-form-field>
|
||||
@@ -97,7 +106,8 @@
|
||||
|
||||
@if (activeGroup() === 'navigation') {
|
||||
<h4 class="form-card__subheading">{{ 'adminCategories.navBreadcrumb' | translate }}</h4>
|
||||
<p>{{ breadcrumb.length > 0 ? breadcrumb.join(' / ') : ('adminCategories.navRoot' | translate) }}</p>
|
||||
<p class="breadcrumb-hint">{{ 'adminCategories.navBreadcrumbHint' | translate }}</p>
|
||||
<p>{{ breadcrumb.length > 0 ? breadcrumb.join(' › ') : ('adminCategories.navRoot' | translate) }}</p>
|
||||
|
||||
<h4 class="form-card__subheading">{{ 'adminCategories.navChildren' | translate }}</h4>
|
||||
@if (children.length === 0) {
|
||||
|
||||
@@ -42,3 +42,8 @@ input[type='checkbox'] { width: auto; }
|
||||
.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; }
|
||||
.icon-field { display: flex; align-items: center; gap: 8px; app-input { flex: 1; } }
|
||||
.icon-field__preview { font-size: 1.4rem; line-height: 1; }
|
||||
.seo-generate-row { display: flex; align-items: center; gap: 10px; }
|
||||
.seo-generate-row__hint { color: var(--text-secondary, #6b7280); font-size: 0.8rem; }
|
||||
.breadcrumb-hint { margin: 0 0 4px; color: var(--text-secondary, #6b7280); font-size: 0.85rem; }
|
||||
|
||||
@@ -78,6 +78,38 @@ export class AdminCategoryFormComponent {
|
||||
this.categoryChange.emit({ [key]: value } as Partial<AdminCategory>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Title edits keep the slug and SEO title in sync only while they still hold
|
||||
* their auto-derived value (or are empty). A manually edited slug/SEO title
|
||||
* no longer matches the derived value, so it is never overwritten.
|
||||
*/
|
||||
updateTitle(value: string): void {
|
||||
const patch: Partial<AdminCategory> = { title: value };
|
||||
const previousAutoSlug = this.slugify(this.category.title);
|
||||
if (!this.category.slug || this.category.slug === previousAutoSlug) {
|
||||
patch.slug = this.slugify(value);
|
||||
}
|
||||
if (!this.category.seo.metaTitle || this.category.seo.metaTitle === this.category.title) {
|
||||
patch.seo = { ...this.category.seo, metaTitle: value };
|
||||
}
|
||||
this.categoryChange.emit(patch);
|
||||
}
|
||||
|
||||
/** Fills empty SEO fields from title/description; existing values stay untouched. */
|
||||
generateSeo(): void {
|
||||
this.categoryChange.emit({
|
||||
seo: {
|
||||
metaTitle: this.category.seo.metaTitle || this.category.title,
|
||||
metaDescription: this.category.seo.metaDescription || this.category.description || this.category.title,
|
||||
keywords: this.category.seo.keywords || this.slugify(this.category.title).split('-').filter(Boolean).join(', '),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private slugify(value: string): string {
|
||||
return value.toLowerCase().trim().replace(/[^a-z0-9а-яёա-ֆ]+/gi, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
updateTranslation(locale: string, field: 'title' | 'description', value: string): void {
|
||||
this.categoryChange.emit({
|
||||
translations: {
|
||||
|
||||
@@ -1417,6 +1417,8 @@ export const en: Translations = {
|
||||
groupAdvanced: 'Advanced',
|
||||
visibilityExplain: 'A hidden category and its products stay out of the storefront navigation and search until you make it visible again.',
|
||||
navBreadcrumb: 'Breadcrumb',
|
||||
navBreadcrumbHint: 'This is the path customers see above this category, e.g. Home › Electronics › Phones. It comes from the parent category you choose.',
|
||||
iconHint: 'A small emoji or symbol shown next to the category name in menus, e.g. 📱. Leave empty for none.',
|
||||
navRoot: 'This is a top-level category.',
|
||||
navChildren: 'Subcategories',
|
||||
navNoChildren: 'No subcategories yet.',
|
||||
|
||||
@@ -1412,6 +1412,8 @@ export const hy: Translations = {
|
||||
groupAdvanced: 'Լրացուցիչ',
|
||||
visibilityExplain: 'Թաքցված կատեգորիան և դրա ապրանքները չեն երևում խանութի նավիգացիայում և որոնման մեջ, մինչև այն նորից տեսանելի դարձնեք։',
|
||||
navBreadcrumb: 'Ուղենիշ',
|
||||
navBreadcrumbHint: 'Սա այն ուղին է, որը գնորդները տեսնում են կատեգորիայի վերևում, օր.՝ Գլխավոր › Էլեկտրոնիկա › Հեռախոսներ։ Այն կառուցվում է ընտրված ծնող կատեգորիայից։',
|
||||
iconHint: 'Փոքր էմոջի կամ նշան կատեգորիայի անվան կողքին մենյուում, օր.՝ 📱։ Թողեք դատարկ, եթե պետք չէ։',
|
||||
navRoot: 'Սա վերին մակարդակի կատեգորիա է։',
|
||||
navChildren: 'Ենթակատեգորիաներ',
|
||||
navNoChildren: 'Ենթակատեգորիաներ դեռ չկան։',
|
||||
|
||||
@@ -1412,6 +1412,8 @@ export const ru: Translations = {
|
||||
groupAdvanced: 'Дополнительно',
|
||||
visibilityExplain: 'Скрытая категория и её товары не отображаются в навигации и поиске магазина, пока вы снова не сделаете её видимой.',
|
||||
navBreadcrumb: 'Хлебные крошки',
|
||||
navBreadcrumbHint: 'Это путь, который покупатели видят над категорией, напр. Главная › Электроника › Телефоны. Он строится из выбранной родительской категории.',
|
||||
iconHint: 'Небольшой эмодзи или символ рядом с названием категории в меню, напр. 📱. Оставьте пустым, если не нужен.',
|
||||
navRoot: 'Это категория верхнего уровня.',
|
||||
navChildren: 'Подкатегории',
|
||||
navNoChildren: 'Подкатегорий пока нет.',
|
||||
|
||||
@@ -1424,6 +1424,8 @@ export interface Translations {
|
||||
groupAdvanced: string;
|
||||
visibilityExplain: string;
|
||||
navBreadcrumb: string;
|
||||
navBreadcrumbHint: string;
|
||||
iconHint: string;
|
||||
navRoot: string;
|
||||
navChildren: string;
|
||||
navNoChildren: string;
|
||||
|
||||
Reference in New Issue
Block a user