feat(admin): visual badge manager with storefront-accurate previews
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> P0 user feedback: badges were a comma-separated text input with no preview. - Replaced with a chip editor: current badges render as removable pills in the exact colors customers see on the storefront (colors sourced from getBadgeClass/item.utils.ts so admin preview and product card never drift) - The standard set (new/sale/exclusive/hot/limited/bestseller/featured) is offered as one-click add chips; custom badges added via text field, unlimited, shown in the storefront's custom-badge grey - Deliberately NOT per-badge arbitrary colors: badge colors are part of the storefront design contract (string[] + fixed palette); introducing per-badge color storage would change the product data shape consumed by the future backend - New adminProducts keys (badgesHint, addBadge, removeBadge, customBadgePlaceholder) in en/ru/hy; verified in browser
This commit is contained in:
@@ -231,8 +231,30 @@
|
|||||||
<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.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>
|
<label class="toggle-row"><app-toggle [ngModel]="product.bestseller" (ngModelChange)="updateField('bestseller', $event)" [ariaLabel]="'adminProducts.bestseller' | translate" /><span>{{ 'adminProducts.bestseller' | translate }}</span></label>
|
||||||
</div>
|
</div>
|
||||||
<app-form-field [label]="'adminProducts.badges' | translate">
|
<app-form-field [label]="'adminProducts.badges' | translate" [hint]="'adminProducts.badgesHint' | translate">
|
||||||
<app-input [ngModel]="product.badges.join(', ')" (ngModelChange)="updateBadges($event)" />
|
<div class="badge-manager">
|
||||||
|
@if (product.badges.length > 0) {
|
||||||
|
<div class="badge-manager__current">
|
||||||
|
@for (badge of product.badges; track badge) {
|
||||||
|
<span class="badge-chip" [style.background]="badgeColor(badge)">
|
||||||
|
{{ badge }}
|
||||||
|
<button type="button" class="badge-chip__remove" (click)="removeBadge(badge)" [attr.aria-label]="('adminProducts.removeBadge' | translate) + ' ' + badge">×</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="badge-manager__known">
|
||||||
|
@for (known of knownBadges; track known) {
|
||||||
|
@if (!product.badges.includes(known)) {
|
||||||
|
<button type="button" class="badge-chip badge-chip--add" [style.background]="badgeColor(known)" (click)="addBadge(known)">+ {{ known }}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="badge-manager__custom">
|
||||||
|
<app-input [ngModel]="customBadge" (ngModelChange)="customBadge = $event" [placeholder]="'adminProducts.customBadgePlaceholder' | translate" />
|
||||||
|
<app-button variant="secondary" size="sm" [disabled]="!customBadge.trim()" (click)="addCustomBadge()">{{ 'adminProducts.addBadge' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,3 +105,18 @@ input[type='checkbox'] { width: auto; }
|
|||||||
color: var(--text-secondary, #6b7280);
|
color: var(--text-secondary, #6b7280);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
.badge-manager { display: grid; gap: 10px; }
|
||||||
|
.badge-manager__current, .badge-manager__known { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.badge-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
min-height: 24px; padding: 3px 10px; border-radius: 999px;
|
||||||
|
color: #fff; font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.badge-chip--add { cursor: pointer; opacity: 0.55; &:hover { opacity: 1; } &:focus-visible { outline: 2px solid var(--color-primary, #2f8f5b); outline-offset: 2px; opacity: 1; } }
|
||||||
|
.badge-chip__remove {
|
||||||
|
border: none; background: transparent; color: inherit; cursor: pointer;
|
||||||
|
font-size: 0.9rem; line-height: 1; padding: 0 0 0 2px;
|
||||||
|
&:focus-visible { outline: 2px solid #fff; outline-offset: 1px; }
|
||||||
|
}
|
||||||
|
.badge-manager__custom { display: flex; gap: 8px; align-items: center; app-input { flex: 1; max-width: 260px; } }
|
||||||
|
|||||||
@@ -140,8 +140,34 @@ export class AdminProductFormComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBadges(value: string): void {
|
/** Storefront badge vocabulary from getBadgeClass (item.utils.ts) - same colors the customer sees. */
|
||||||
this.productChange.emit({ badges: value.split(',').map(item => item.trim()).filter(Boolean) });
|
readonly knownBadges = ['new', 'sale', 'exclusive', 'hot', 'limited', 'bestseller', 'featured'];
|
||||||
|
private readonly badgeColors: Record<string, string> = {
|
||||||
|
new: '#4caf50', sale: '#f44336', exclusive: '#9c27b0', hot: '#ff5722',
|
||||||
|
limited: '#ff9800', bestseller: '#2196f3', featured: '#607d8b',
|
||||||
|
};
|
||||||
|
|
||||||
|
customBadge = '';
|
||||||
|
|
||||||
|
badgeColor(badge: string): string {
|
||||||
|
return this.badgeColors[badge.toLowerCase()] ?? '#78909c';
|
||||||
|
}
|
||||||
|
|
||||||
|
addBadge(badge: string): void {
|
||||||
|
const value = badge.trim();
|
||||||
|
if (!value || this.product.badges.includes(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.productChange.emit({ badges: [...this.product.badges, value] });
|
||||||
|
}
|
||||||
|
|
||||||
|
addCustomBadge(): void {
|
||||||
|
this.addBadge(this.customBadge);
|
||||||
|
this.customBadge = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBadge(badge: string): void {
|
||||||
|
this.productChange.emit({ badges: this.product.badges.filter(item => item !== badge) });
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly createSpecRow = () => ({ key: '', value: '' });
|
readonly createSpecRow = () => ({ key: '', value: '' });
|
||||||
|
|||||||
@@ -1546,6 +1546,10 @@ export const en: Translations = {
|
|||||||
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.',
|
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.',
|
||||||
|
badgesHint: 'Badges are the small colored labels customers see on the product photo. Pick from the standard set or add your own.',
|
||||||
|
addBadge: 'Add',
|
||||||
|
removeBadge: 'Remove badge',
|
||||||
|
customBadgePlaceholder: 'Your own badge text…',
|
||||||
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.',
|
||||||
|
|||||||
@@ -1541,6 +1541,10 @@ export const hy: Translations = {
|
|||||||
seoGenerate: 'Լրացնել ապրանքի տվյալներից',
|
seoGenerate: 'Լրացնել ապրանքի տվյալներից',
|
||||||
seoGenerateHint: 'Լրացնում է դատարկ դաշտերը անունից և կարճ նկարագրությունից։ Ձեր տեքստը չի փոխվում։',
|
seoGenerateHint: 'Լրացնում է դատարկ դաշտերը անունից և կարճ նկարագրությունից։ Ձեր տեքստը չի փոխվում։',
|
||||||
translationsHint: 'Վերևի հիմնական դաշտերը ցուցադրվում են խանութի հիմնական լեզվով։ Այստեղ ավելացրեք թարգմանություններ այլ լեզուների համար — եթե թարգմանությունը դատարկ է, գնորդները կտեսնեն հիմնական տեքստը։',
|
translationsHint: 'Վերևի հիմնական դաշտերը ցուցադրվում են խանութի հիմնական լեզվով։ Այստեղ ավելացրեք թարգմանություններ այլ լեզուների համար — եթե թարգմանությունը դատարկ է, գնորդները կտեսնեն հիմնական տեքստը։',
|
||||||
|
badgesHint: 'Բեյջերը փոքր գունավոր պիտակներ են, որոնք գնորդները տեսնում են ապրանքի նկարի վրա։ Ընտրեք ստանդարտներից կամ ավելացրեք ձերը։',
|
||||||
|
addBadge: 'Ավելացնել',
|
||||||
|
removeBadge: 'Հեռացնել բեյջը',
|
||||||
|
customBadgePlaceholder: 'Ձեր բեյջի տեքստը…',
|
||||||
seoExplain: 'Սա այն է, ինչ երևում է որոնման արդյունքներում․ հստակ վերնագիրն ու նկարագրությունը օգնում են գտնել այս ապրանքը։',
|
seoExplain: 'Սա այն է, ինչ երևում է որոնման արդյունքներում․ հստակ վերնագիրն ու նկարագրությունը օգնում են գտնել այս ապրանքը։',
|
||||||
searchTitle: 'Որոնման վերնագիր',
|
searchTitle: 'Որոնման վերնագիր',
|
||||||
searchTitleHint: 'Վերնագիրը, որ երևում է որոնման արդյունքներում։ Պահեք կարճ։',
|
searchTitleHint: 'Վերնագիրը, որ երևում է որոնման արդյունքներում։ Պահեք կարճ։',
|
||||||
|
|||||||
@@ -1541,6 +1541,10 @@ export const ru: Translations = {
|
|||||||
seoGenerate: 'Заполнить из данных товара',
|
seoGenerate: 'Заполнить из данных товара',
|
||||||
seoGenerateHint: 'Заполняет пустые поля из названия и краткого описания. Ваш текст не перезаписывается.',
|
seoGenerateHint: 'Заполняет пустые поля из названия и краткого описания. Ваш текст не перезаписывается.',
|
||||||
translationsHint: 'Основные поля выше отображаются на языке магазина по умолчанию. Здесь добавьте переводы для других языков — если перевод пуст, покупатели увидят текст по умолчанию.',
|
translationsHint: 'Основные поля выше отображаются на языке магазина по умолчанию. Здесь добавьте переводы для других языков — если перевод пуст, покупатели увидят текст по умолчанию.',
|
||||||
|
badgesHint: 'Бейджи — небольшие цветные ярлыки, которые покупатели видят на фото товара. Выберите из стандартных или добавьте свой.',
|
||||||
|
addBadge: 'Добавить',
|
||||||
|
removeBadge: 'Убрать бейдж',
|
||||||
|
customBadgePlaceholder: 'Текст своего бейджа…',
|
||||||
seoExplain: 'Это то, что видно в результатах поиска — понятные заголовок и описание помогают найти товар.',
|
seoExplain: 'Это то, что видно в результатах поиска — понятные заголовок и описание помогают найти товар.',
|
||||||
searchTitle: 'Заголовок для поиска',
|
searchTitle: 'Заголовок для поиска',
|
||||||
searchTitleHint: 'Заголовок, показываемый в результатах поиска. Делайте его коротким.',
|
searchTitleHint: 'Заголовок, показываемый в результатах поиска. Делайте его коротким.',
|
||||||
|
|||||||
@@ -1553,6 +1553,10 @@ export interface Translations {
|
|||||||
seoGenerate: string;
|
seoGenerate: string;
|
||||||
seoGenerateHint: string;
|
seoGenerateHint: string;
|
||||||
translationsHint: string;
|
translationsHint: string;
|
||||||
|
badgesHint: string;
|
||||||
|
addBadge: string;
|
||||||
|
removeBadge: string;
|
||||||
|
customBadgePlaceholder: string;
|
||||||
seoExplain: string;
|
seoExplain: string;
|
||||||
searchTitle: string;
|
searchTitle: string;
|
||||||
searchTitleHint: string;
|
searchTitleHint: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user