From b16e3002e440e088c91b2b698b57718d3199c064 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sun, 19 Jul 2026 08:30:49 +0400 Subject: [PATCH] feat(admin): product editor auto-slug, SKU helper, one-click SEO fill Co-Authored-By: Claude Fable 5 P0 user feedback: admins did not know what SKU means, had to hand-write slugs, and left SEO empty. - Name edits now derive the URL slug automatically (supports Cyrillic/Armenian characters); the derivation stops the moment the slug no longer matches the auto value, so a manually edited slug is never overwritten - verified in browser - SEO title mirrors the name under the same only-while-untouched rule - SKU field explains itself (what a stock keeping unit is, that any unique text works) and gains a Generate button producing readable codes like WIR-GAM-7K2P from the product name - SEO tab gains 'Fill from product details': fills only empty title/description/keywords from name and short description, existing text untouched - New adminProducts keys (slugHint, skuHint, generate, seoGenerate, seoGenerateHint) in en/ru/hy --- .../admin-product-form.component.html | 15 +++++-- .../admin-product-form.component.scss | 3 ++ .../admin-product-form.component.ts | 39 +++++++++++++++++++ src/app/i18n/en.ts | 5 +++ src/app/i18n/hy.ts | 5 +++ src/app/i18n/ru.ts | 5 +++ src/app/i18n/translations.ts | 5 +++ 7 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/app/features/admin/products/components/admin-product-form.component.html b/src/app/features/admin/products/components/admin-product-form.component.html index d5901ce..921d0a2 100644 --- a/src/app/features/admin/products/components/admin-product-form.component.html +++ b/src/app/features/admin/products/components/admin-product-form.component.html @@ -24,13 +24,16 @@ @if (activeGroup() === 'general') {
- + - + - - + +
+ + {{ 'adminProducts.generate' | translate }} +
@@ -191,6 +194,10 @@ @if (activeGroup() === 'seo') {

{{ 'adminProducts.seoExplain' | translate }}

+
+ {{ 'adminProducts.seoGenerate' | translate }} + {{ 'adminProducts.seoGenerateHint' | translate }} +
diff --git a/src/app/features/admin/products/components/admin-product-form.component.scss b/src/app/features/admin/products/components/admin-product-form.component.scss index c0c9de3..bd5bf09 100644 --- a/src/app/features/admin/products/components/admin-product-form.component.scss +++ b/src/app/features/admin/products/components/admin-product-form.component.scss @@ -72,3 +72,6 @@ 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; } +.field-with-action { display: flex; gap: 8px; align-items: center; app-input { flex: 1; } } +.seo-generate-row { display: flex; align-items: center; gap: 10px; } +.seo-generate-row__hint { color: var(--text-secondary, #6b7280); font-size: 0.8rem; } diff --git a/src/app/features/admin/products/components/admin-product-form.component.ts b/src/app/features/admin/products/components/admin-product-form.component.ts index 7a47494..0c7b901 100644 --- a/src/app/features/admin/products/components/admin-product-form.component.ts +++ b/src/app/features/admin/products/components/admin-product-form.component.ts @@ -82,6 +82,45 @@ export class AdminProductFormComponent { this.productChange.emit({ [key]: value } as Partial); } + /** + * Name edits keep slug and SEO title in sync while (and only while) those + * fields still hold their auto-derived value or are empty. The moment the + * admin edits slug/SEO manually the derived value no longer matches, so we + * stop regenerating - manual input is never overwritten. + */ + updateName(value: string): void { + const patch: Partial = { name: value }; + const previousAutoSlug = this.slugify(this.product.name); + if (!this.product.slug || this.product.slug === previousAutoSlug) { + patch.slug = this.slugify(value); + } + if (!this.product.seo.metaTitle || this.product.seo.metaTitle === this.product.name) { + patch.seo = { ...this.product.seo, metaTitle: value }; + } + this.productChange.emit(patch); + } + + generateSku(): void { + const prefix = this.slugify(this.product.name).split('-').slice(0, 2).map(part => part.slice(0, 3).toUpperCase()).join('-') || 'SKU'; + const suffix = Date.now().toString(36).slice(-4).toUpperCase(); + this.productChange.emit({ sku: `${prefix}-${suffix}` }); + } + + /** Fills empty SEO fields from name/short description; existing values stay untouched. */ + generateSeo(): void { + this.productChange.emit({ + seo: { + metaTitle: this.product.seo.metaTitle || this.product.name, + metaDescription: this.product.seo.metaDescription || this.product.shortDescription || this.product.name, + keywords: this.product.seo.keywords || this.slugify(this.product.name).split('-').filter(Boolean).join(', '), + }, + }); + } + + private slugify(value: string): string { + return value.toLowerCase().trim().replace(/[^a-z0-9а-яёա-ֆ]+/gi, '-').replace(/(^-|-$)/g, ''); + } + updateTranslation(locale: string, field: 'name' | 'shortDescription' | 'htmlDescription' | 'seoTitle' | 'seoDescription', value: string): void { this.productChange.emit({ translations: { diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 19011b2..fd39939 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -1538,6 +1538,11 @@ export const en: Translations = { variantsHint: 'Each variant is a purchasable option of this product, e.g. a size or color, with its own price and stock.', variants: 'Variants', archived: 'Archived', + slugHint: 'The web address of this product. Filled in automatically from the name - edit only if you need a custom URL.', + skuHint: 'SKU (stock keeping unit) is your internal product code, used for inventory and order lookups. Any unique text works.', + generate: 'Generate', + seoGenerate: 'Fill from product details', + seoGenerateHint: 'Fills empty fields from the product name and short description. Your existing text is kept.', 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.', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 2ac2229..147344e 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -1533,6 +1533,11 @@ export const hy: Translations = { variantsHint: 'Յուրաքանչյուր տարբերակ այս ապրանքի առանձին գնվող տարբերակ է (օր․՝ չափս կամ գույն)՝ իր գնով և մնացորդով։', variants: 'Տարբերակներ', archived: 'Արխիվում', + slugHint: 'Ապրանքի վեբ-հասցեն։ Լրացվում է ինքնաշխատ անունից — փոխեք միայն, եթե պետք է հատուկ URL։', + skuHint: 'SKU (ապրանքային կոդ) — ձեր ներքին կոդն է պահեստի հաշվառման և պատվերների որոնման համար։ Ցանկացած եզակի տեքստ կաշխատի։', + generate: 'Գեներացնել', + seoGenerate: 'Լրացնել ապրանքի տվյալներից', + seoGenerateHint: 'Լրացնում է դատարկ դաշտերը անունից և կարճ նկարագրությունից։ Ձեր տեքստը չի փոխվում։', seoExplain: 'Սա այն է, ինչ երևում է որոնման արդյունքներում․ հստակ վերնագիրն ու նկարագրությունը օգնում են գտնել այս ապրանքը։', searchTitle: 'Որոնման վերնագիր', searchTitleHint: 'Վերնագիրը, որ երևում է որոնման արդյունքներում։ Պահեք կարճ։', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 386e8a7..6f9b254 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -1533,6 +1533,11 @@ export const ru: Translations = { variantsHint: 'Каждый вариант — это отдельный покупаемый вариант товара (например, размер или цвет) со своей ценой и остатком.', variants: 'Варианты', archived: 'В архиве', + slugHint: 'Веб-адрес этого товара. Заполняется автоматически из названия — меняйте, только если нужен особый URL.', + skuHint: 'SKU (артикул) — ваш внутренний код товара для учёта склада и поиска заказов. Подойдёт любой уникальный текст.', + generate: 'Сгенерировать', + seoGenerate: 'Заполнить из данных товара', + seoGenerateHint: 'Заполняет пустые поля из названия и краткого описания. Ваш текст не перезаписывается.', seoExplain: 'Это то, что видно в результатах поиска — понятные заголовок и описание помогают найти товар.', searchTitle: 'Заголовок для поиска', searchTitleHint: 'Заголовок, показываемый в результатах поиска. Делайте его коротким.', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 66b4c1d..bb3fb4f 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -1545,6 +1545,11 @@ export interface Translations { variantsHint: string; variants: string; archived: string; + slugHint: string; + skuHint: string; + generate: string; + seoGenerate: string; + seoGenerateHint: string; seoExplain: string; searchTitle: string; searchTitleHint: string;