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;