feat(admin): collapse product translations behind default-language fields

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

P0 user feedback: four parallel description fields (default/ru/en/hy) read as duplicates and confused the admin.

- The default-language name/short description/rich description now stand alone; per-language fields moved into a collapsed 'Translations' disclosure with a translated-count badge (e.g. 1/3)
- The disclosure explains the fallback rule in merchant language: empty translation means customers see the default text
- Translation inputs show the default value as placeholder, making the fallback visible instead of implied
- Locale codes shown uppercase (RU/EN/HY) to read as language labels, not field suffixes
This commit is contained in:
sdarbinyan
2026-07-19 08:40:30 +04:00
parent 0f81643744
commit 31c64e9647
7 changed files with 74 additions and 19 deletions

View File

@@ -49,17 +49,24 @@
<app-input [ngModel]="product.shortDescription" (ngModelChange)="updateField('shortDescription', $event)" /> <app-input [ngModel]="product.shortDescription" (ngModelChange)="updateField('shortDescription', $event)" />
</app-form-field> </app-form-field>
<h4 class="form-card__subheading">{{ 'adminProducts.translations' | translate }}</h4> <details class="translations-block">
@for (locale of locales; track locale) { <summary>
<div class="grid two sub-block"> <span class="pi pi-globe" aria-hidden="true"></span>
<app-form-field [label]="(('adminProducts.name' | translate) + ' ' + locale)"> {{ 'adminProducts.translations' | translate }}
<app-input [ngModel]="product.translations[locale]?.name || ''" (ngModelChange)="updateTranslation(locale, 'name', $event)" /> <span class="translations-block__count">{{ translatedLocalesCount() }}/{{ locales.length }}</span>
</app-form-field> </summary>
<app-form-field [label]="(('adminProducts.shortDescription' | translate) + ' ' + locale)"> <p class="translations-block__hint">{{ 'adminProducts.translationsHint' | translate }}</p>
<app-input [ngModel]="product.translations[locale]?.shortDescription || ''" (ngModelChange)="updateTranslation(locale, 'shortDescription', $event)" /> @for (locale of locales; track locale) {
</app-form-field> <div class="grid two sub-block">
</div> <app-form-field [label]="(('adminProducts.name' | translate) + ' ' + locale.toUpperCase())">
} <app-input [ngModel]="product.translations[locale]?.name || ''" (ngModelChange)="updateTranslation(locale, 'name', $event)" [placeholder]="product.name" />
</app-form-field>
<app-form-field [label]="(('adminProducts.shortDescription' | translate) + ' ' + locale.toUpperCase())">
<app-input [ngModel]="product.translations[locale]?.shortDescription || ''" (ngModelChange)="updateTranslation(locale, 'shortDescription', $event)" [placeholder]="product.shortDescription" />
</app-form-field>
</div>
}
</details>
} }
@if (activeGroup() === 'media') { @if (activeGroup() === 'media') {
@@ -233,14 +240,21 @@
<h4 class="form-card__subheading">{{ 'adminProducts.htmlDescription' | translate }}</h4> <h4 class="form-card__subheading">{{ 'adminProducts.htmlDescription' | translate }}</h4>
<app-marketplace-html-editor [html]="product.htmlDescription" (htmlChange)="updateField('htmlDescription', $event)" /> <app-marketplace-html-editor [html]="product.htmlDescription" (htmlChange)="updateField('htmlDescription', $event)" />
@for (locale of locales; track locale) { <details class="translations-block">
<label class="full sub-block"><span>{{ 'adminProducts.htmlDescription' | translate }} {{ locale }}</span> <summary>
<app-marketplace-html-editor <span class="pi pi-globe" aria-hidden="true"></span>
[html]="product.translations[locale]?.htmlDescription || ''" {{ 'adminProducts.translations' | translate }}
(htmlChange)="updateTranslation(locale, 'htmlDescription', $event)" </summary>
/> <p class="translations-block__hint">{{ 'adminProducts.translationsHint' | translate }}</p>
</label> @for (locale of locales; track locale) {
} <label class="full sub-block"><span>{{ 'adminProducts.htmlDescription' | translate }} — {{ locale.toUpperCase() }}</span>
<app-marketplace-html-editor
[html]="product.translations[locale]?.htmlDescription || ''"
(htmlChange)="updateTranslation(locale, 'htmlDescription', $event)"
/>
</label>
}
</details>
<h4 class="form-card__subheading">{{ 'adminProducts.customer' | translate }}</h4> <h4 class="form-card__subheading">{{ 'adminProducts.customer' | translate }}</h4>
<div class="readonly-grid"> <div class="readonly-grid">

View File

@@ -75,3 +75,33 @@ input[type='checkbox'] { width: auto; }
.field-with-action { display: flex; gap: 8px; align-items: center; app-input { flex: 1; } } .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 { display: flex; align-items: center; gap: 10px; }
.seo-generate-row__hint { color: var(--text-secondary, #6b7280); font-size: 0.8rem; } .seo-generate-row__hint { color: var(--text-secondary, #6b7280); font-size: 0.8rem; }
.translations-block {
border: 1px solid var(--border-color, #d3dad9);
border-radius: var(--radius-sm);
padding: 10px 12px;
summary {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
&:focus-visible { outline: 2px solid var(--color-primary, #2f8f5b); outline-offset: 2px; }
}
&[open] summary { margin-bottom: 8px; }
}
.translations-block__count {
margin-left: auto;
color: var(--text-secondary, #6b7280);
font-size: 0.8rem;
font-weight: 400;
}
.translations-block__hint {
margin: 0 0 10px;
color: var(--text-secondary, #6b7280);
font-size: 0.85rem;
}

View File

@@ -121,6 +121,13 @@ export class AdminProductFormComponent {
return value.toLowerCase().trim().replace(/[^a-z0-9а-яёա-ֆ]+/gi, '-').replace(/(^-|-$)/g, ''); return value.toLowerCase().trim().replace(/[^a-z0-9а-яёա-ֆ]+/gi, '-').replace(/(^-|-$)/g, '');
} }
translatedLocalesCount(): number {
return this.locales.filter(locale => {
const tr = this.product.translations[locale];
return !!(tr && (tr.name || tr.shortDescription || tr.htmlDescription));
}).length;
}
updateTranslation(locale: string, field: 'name' | 'shortDescription' | 'htmlDescription' | 'seoTitle' | 'seoDescription', value: string): void { updateTranslation(locale: string, field: 'name' | 'shortDescription' | 'htmlDescription' | 'seoTitle' | 'seoDescription', value: string): void {
this.productChange.emit({ this.productChange.emit({
translations: { translations: {

View File

@@ -1545,6 +1545,7 @@ export const en: Translations = {
generate: 'Generate', generate: 'Generate',
seoGenerate: 'Fill from product details', seoGenerate: 'Fill from product details',
seoGenerateHint: 'Fills empty fields from the product name and short description. Your existing text is kept.', seoGenerateHint: 'Fills empty fields from the product name and short description. Your existing text is kept.',
translationsHint: 'The main fields above are shown in your default store language. Add translations here for other languages - if a translation is empty, customers see the default text instead.',
seoExplain: 'This is what shows up in search results — a clear title and description help customers find this product.', seoExplain: 'This is what shows up in search results — a clear title and description help customers find this product.',
searchTitle: 'Search title', searchTitle: 'Search title',
searchTitleHint: 'The headline shown in search results. Keep it short and descriptive.', searchTitleHint: 'The headline shown in search results. Keep it short and descriptive.',

View File

@@ -1540,6 +1540,7 @@ export const hy: Translations = {
generate: 'Գեներացնել', generate: 'Գեներացնել',
seoGenerate: 'Լրացնել ապրանքի տվյալներից', seoGenerate: 'Լրացնել ապրանքի տվյալներից',
seoGenerateHint: 'Լրացնում է դատարկ դաշտերը անունից և կարճ նկարագրությունից։ Ձեր տեքստը չի փոխվում։', seoGenerateHint: 'Լրացնում է դատարկ դաշտերը անունից և կարճ նկարագրությունից։ Ձեր տեքստը չի փոխվում։',
translationsHint: 'Վերևի հիմնական դաշտերը ցուցադրվում են խանութի հիմնական լեզվով։ Այստեղ ավելացրեք թարգմանություններ այլ լեզուների համար — եթե թարգմանությունը դատարկ է, գնորդները կտեսնեն հիմնական տեքստը։',
seoExplain: 'Սա այն է, ինչ երևում է որոնման արդյունքներում․ հստակ վերնագիրն ու նկարագրությունը օգնում են գտնել այս ապրանքը։', seoExplain: 'Սա այն է, ինչ երևում է որոնման արդյունքներում․ հստակ վերնագիրն ու նկարագրությունը օգնում են գտնել այս ապրանքը։',
searchTitle: 'Որոնման վերնագիր', searchTitle: 'Որոնման վերնագիր',
searchTitleHint: 'Վերնագիրը, որ երևում է որոնման արդյունքներում։ Պահեք կարճ։', searchTitleHint: 'Վերնագիրը, որ երևում է որոնման արդյունքներում։ Պահեք կարճ։',

View File

@@ -1540,6 +1540,7 @@ export const ru: Translations = {
generate: 'Сгенерировать', generate: 'Сгенерировать',
seoGenerate: 'Заполнить из данных товара', seoGenerate: 'Заполнить из данных товара',
seoGenerateHint: 'Заполняет пустые поля из названия и краткого описания. Ваш текст не перезаписывается.', seoGenerateHint: 'Заполняет пустые поля из названия и краткого описания. Ваш текст не перезаписывается.',
translationsHint: 'Основные поля выше отображаются на языке магазина по умолчанию. Здесь добавьте переводы для других языков — если перевод пуст, покупатели увидят текст по умолчанию.',
seoExplain: 'Это то, что видно в результатах поиска — понятные заголовок и описание помогают найти товар.', seoExplain: 'Это то, что видно в результатах поиска — понятные заголовок и описание помогают найти товар.',
searchTitle: 'Заголовок для поиска', searchTitle: 'Заголовок для поиска',
searchTitleHint: 'Заголовок, показываемый в результатах поиска. Делайте его коротким.', searchTitleHint: 'Заголовок, показываемый в результатах поиска. Делайте его коротким.',

View File

@@ -1552,6 +1552,7 @@ export interface Translations {
generate: string; generate: string;
seoGenerate: string; seoGenerate: string;
seoGenerateHint: string; seoGenerateHint: string;
translationsHint: string;
seoExplain: string; seoExplain: string;
searchTitle: string; searchTitle: string;
searchTitleHint: string; searchTitleHint: string;