added translation

This commit is contained in:
sdarbinyan
2026-02-20 09:01:02 +04:00
parent 083b270c74
commit 6850a911f3
22 changed files with 1219 additions and 136 deletions

View File

@@ -0,0 +1,234 @@
// Add new languages by name here — no other files need to change.
export const TRANSLATIONS: Record<string, Record<string, string>> = {
en: {
// --- Dashboard ---
MARKETPLACE_BACKOFFICE: 'Marketplace Backoffice',
ACTIVE: 'Active',
INACTIVE: 'Inactive',
PROJECTS: 'Projects',
// --- Navigation / Project view ---
CATEGORIES: 'Categories',
ADD_CATEGORY: 'Add Category',
ADD_SUBCATEGORY: 'Add Subcategory',
VIEW_ITEMS: 'View Items',
WELCOME_TO: 'Welcome to',
BACKOFFICE: 'Backoffice',
SELECT_FROM_SIDEBAR: 'Select a category or subcategory from the sidebar to start editing.',
// --- Common editor ---
EDIT: 'Edit',
EDIT_CATEGORY: 'Edit Category',
EDIT_SUBCATEGORY: 'Edit Subcategory',
EDIT_ITEM: 'Edit Item',
NAME: 'Name',
ID: 'ID',
PRIORITY: 'Priority',
PRIORITY_HINT: 'Lower numbers appear first',
VISIBLE: 'Visible',
IMAGE_URL: 'Or enter image URL',
UPLOAD_IMAGE: 'Upload Image',
IMAGE: 'Image',
SAVING: 'Saving...',
SAVE: 'Save',
DELETE: 'Delete',
DELETE_CATEGORY: 'Delete Category',
DELETE_SUBCATEGORY: 'Delete Subcategory',
TOGGLE_VISIBILITY: 'Toggle Visibility',
PREVIEW: 'Preview',
CANCEL: 'Cancel',
CREATE: 'Create',
SUBCATEGORIES: 'Subcategories',
NO_SUBCATEGORIES: 'No subcategories yet',
// --- Item editor tabs ---
BASIC_INFO: 'Basic Info',
IMAGES: 'Images',
TAGS: 'Tags',
BADGES: 'Badges',
DESCRIPTION: 'Description',
COMMENTS: 'Comments',
TRANSLATIONS: 'Translations',
// --- Item basic fields ---
ITEM_NAME: 'Item Name',
PRICE: 'Price',
QUANTITY: 'Quantity',
CURRENCY: 'Currency',
SIMPLE_DESCRIPTION: 'Simple Description',
// --- Tags / Badges ---
ADD_TAG: 'Add Tag',
ADD_BADGE: 'Custom Badge',
TAG_PLACEHOLDER: 'e.g. new, sale, featured',
BADGE_PLACEHOLDER: 'e.g. pre-order',
NO_TAGS: 'No tags yet',
NO_BADGES: 'No badges yet',
PREDEFINED_BADGES: 'Predefined Badges',
PREDEFINED_BADGES_HINT: 'Toggle badges to highlight this item in the marketplace.',
CUSTOM_BADGES: 'Custom Badges',
// --- Description tab ---
DESC_HINT: 'Add structured information like color, size, material, etc.',
DESC_KEY: 'Key',
DESC_VALUE: 'Value',
DESC_KEY_PLACEHOLDER: 'e.g. Color',
DESC_VALUE_PLACEHOLDER: 'e.g. Red',
// --- Comments ---
NO_COMMENTS: 'No comments yet',
NO_IMAGES: 'No images yet',
NO_DESC_FIELDS: 'No description fields yet',
ADD_FIELD: 'Add Field',
QTY: 'Qty',
ITEMS_COUNT: 'items',
IN_STOCK: 'In stock',
OUT_OF_STOCK: 'Out of stock',
OUT_OF_STOCK_BANNER: 'Out of Stock',
ITEM_NOT_FOUND: 'Item not found',
GO_BACK: 'Go back',
SECTION_BADGES: 'Badges',
SECTION_TAGS: 'Tags',
// --- Items list ---
SEARCH_ITEMS: 'Search items',
SEARCH_PLACEHOLDER: 'Search by name...',
VISIBILITY: 'Visibility',
ALL: 'All',
HIDDEN: 'Hidden',
SELECTED: 'selected',
NO_ITEMS_FOUND: 'No items found',
LOADING_MORE: 'Loading more items...',
NO_MORE_ITEMS: 'No more items to load',
SHOW: 'Show',
HIDE: 'Hide',
// --- Translations tab ---
TRANSLATIONS_HINT: 'Fill in translations for marketplace localization.',
TRANSLATIONS_LANG_LABEL: 'Russian Translation (RU)',
NAME_TRANSLATED: 'Name (Russian)',
SIMPLE_DESC_TRANSLATED: 'Simple Description (Russian)',
DESC_TRANSLATED: 'Description (Russian)',
DESC_KEY_RU: 'Key (RU)',
DESC_VALUE_RU: 'Value (RU)',
ADD_DESC_ROW: 'Add Row',
NO_TRANSLATIONS: 'No Russian translation yet',
TRANSLATION_SAVED: 'Translation saved',
},
ru: {
// --- Dashboard ---
MARKETPLACE_BACKOFFICE: 'Бэкофис маркетплейса',
ACTIVE: 'Активен',
INACTIVE: 'Неактивен',
PROJECTS: 'Проекты',
// --- Navigation / Project view ---
CATEGORIES: 'Категории',
ADD_CATEGORY: 'Добавить категорию',
ADD_SUBCATEGORY: 'Добавить подкатегорию',
VIEW_ITEMS: 'Товары',
WELCOME_TO: 'Добро пожаловать,',
BACKOFFICE: 'Бэкофис',
SELECT_FROM_SIDEBAR: 'Выберите категорию или подкатегорию в боковом меню для редактирования.',
// --- Common editor ---
EDIT: 'Редактировать',
EDIT_CATEGORY: 'Редактировать категорию',
EDIT_SUBCATEGORY: 'Редактировать подкатегорию',
EDIT_ITEM: 'Редактировать товар',
NAME: 'Название',
ID: 'ID',
PRIORITY: 'Приоритет',
PRIORITY_HINT: 'Меньшее число — выше в списке',
VISIBLE: 'Видимый',
IMAGE_URL: 'Или введите URL изображения',
UPLOAD_IMAGE: 'Загрузить фото',
IMAGE: 'Изображение',
SAVING: 'Сохранение...',
SAVE: 'Сохранить',
DELETE: 'Удалить',
DELETE_CATEGORY: 'Удалить категорию',
DELETE_SUBCATEGORY: 'Удалить подкатегорию',
TOGGLE_VISIBILITY: 'Переключить видимость',
PREVIEW: 'Предпросмотр',
CANCEL: 'Отмена',
CREATE: 'Создать',
SUBCATEGORIES: 'Подкатегории',
NO_SUBCATEGORIES: 'Нет подкатегорий',
// --- Item editor tabs ---
BASIC_INFO: 'Основное',
IMAGES: 'Изображения',
TAGS: 'Теги',
BADGES: 'Метки',
DESCRIPTION: 'Описание',
COMMENTS: 'Комментарии',
TRANSLATIONS: 'Переводы',
// --- Item basic fields ---
ITEM_NAME: 'Название товара',
PRICE: 'Цена',
QUANTITY: 'Количество',
CURRENCY: 'Валюта',
SIMPLE_DESCRIPTION: 'Краткое описание',
// --- Tags / Badges ---
ADD_TAG: 'Добавить тег',
ADD_BADGE: 'Своя метка',
TAG_PLACEHOLDER: 'напр. новинка, распродажа',
BADGE_PLACEHOLDER: 'напр. предзаказ',
NO_TAGS: 'Тегов нет',
NO_BADGES: 'Меток нет',
PREDEFINED_BADGES: 'Стандартные метки',
PREDEFINED_BADGES_HINT: 'Включите метки для выделения товара на маркетплейсе.',
CUSTOM_BADGES: 'Свои метки',
// --- Description tab ---
DESC_HINT: 'Добавьте характеристики: цвет, размер, материал и т.д.',
DESC_KEY: 'Ключ',
DESC_VALUE: 'Значение',
DESC_KEY_PLACEHOLDER: 'напр. Цвет',
DESC_VALUE_PLACEHOLDER: 'напр. Красный',
// --- Comments ---
NO_COMMENTS: 'Комментариев нет',
NO_IMAGES: 'Изображений нет',
NO_DESC_FIELDS: 'Полей описания нет',
ADD_FIELD: 'Добавить поле',
QTY: 'Кол-во',
ITEMS_COUNT: 'товаров',
IN_STOCK: 'В наличии',
OUT_OF_STOCK: 'Нет в наличии',
OUT_OF_STOCK_BANNER: 'Нет в наличии',
ITEM_NOT_FOUND: 'Товар не найден',
GO_BACK: 'Назад',
SECTION_BADGES: 'Метки',
SECTION_TAGS: 'Теги',
// --- Items list ---
SEARCH_ITEMS: 'Поиск товаров',
SEARCH_PLACEHOLDER: 'Поиск по названию...',
VISIBILITY: 'Видимость',
ALL: 'Все',
HIDDEN: 'Скрытые',
SELECTED: 'выбрано',
NO_ITEMS_FOUND: 'Товары не найдены',
LOADING_MORE: 'Загружаем товары...',
NO_MORE_ITEMS: 'Все товары загружены',
SHOW: 'Показать',
HIDE: 'Скрыть',
// --- Translations tab ---
TRANSLATIONS_HINT: 'Переводы для локализации маркетплейса.',
TRANSLATIONS_LANG_LABEL: 'Русский перевод (RU)',
NAME_TRANSLATED: 'Название (Русский)',
SIMPLE_DESC_TRANSLATED: 'Краткое описание (Русский)',
DESC_TRANSLATED: 'Описание (Русский)',
DESC_KEY_RU: 'Ключ (RU)',
DESC_VALUE_RU: 'Значение (RU)',
ADD_DESC_ROW: 'Добавить строку',
NO_TRANSLATIONS: 'Русский перевод не заполнен',
TRANSLATION_SAVED: 'Перевод сохранён',
},
};

View File

@@ -1,3 +1,11 @@
/**
* Per-language translation content for a category or subcategory.
* Stored under `translations['ru']`, `translations['en']`, etc.
*/
export interface CategoryTranslation {
name?: string;
}
export interface Category {
id: string;
name: string;
@@ -6,6 +14,8 @@ export interface Category {
img?: string;
projectId: string;
subcategories?: Subcategory[];
/** Optional translations keyed by language code: { ru: { name: '...' } } */
translations?: { [lang: string]: CategoryTranslation };
}
export interface Subcategory {
@@ -21,4 +31,6 @@ export interface Subcategory {
itemCount?: number;
subcategories?: Subcategory[];
hasItems?: boolean;
/** Optional translations keyed by language code: { ru: { name: '...' } } */
translations?: { [lang: string]: CategoryTranslation };
}

View File

@@ -1,3 +1,12 @@
/**
* Per-language translation content for an item.
*/
export interface ItemTranslation {
name?: string;
simpleDescription?: string;
description?: ItemDescriptionField[];
}
export interface Item {
id: string;
name: string;
@@ -13,6 +22,8 @@ export interface Item {
description: ItemDescriptionField[];
subcategoryId: string;
comments?: Comment[];
/** Optional translations keyed by language code: { ru: { name: '...', simpleDescription: '...', description: [...] } } */
translations?: { [lang: string]: ItemTranslation };
}
export interface ItemDescriptionField {

View File

@@ -6,30 +6,18 @@
<button mat-icon-button (click)="goBack()">
<mat-icon>close</mat-icon>
</button>
<h2>Edit Category</h2>
<h2>{{ 'EDIT_CATEGORY' | translate }}</h2>
@if (saving()) {
<span class="save-indicator">Saving...</span>
<span class="save-indicator">{{ 'SAVING' | translate }}</span>
}
<button mat-icon-button color="warn" (click)="deleteCategory()" matTooltip="Delete Category">
<button mat-icon-button color="warn" (click)="deleteCategory()" [matTooltip]="'DELETE_CATEGORY' | translate">
<mat-icon>delete</mat-icon>
</button>
</div>
<div class="editor-content">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Name</mat-label>
<input
matInput
[(ngModel)]="category()!.name"
(blur)="onFieldChange('name', category()!.name)"
required>
@if (!category()!.name || category()!.name.trim().length === 0) {
<mat-error>Category name is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>ID</mat-label>
<mat-label>{{ 'NAME' | translate }}</mat-label>
<input matInput [value]="category()!.id" disabled>
</mat-form-field>
@@ -38,12 +26,12 @@
[(ngModel)]="category()!.visible"
(change)="onFieldChange('visible', category()!.visible)"
color="primary">
Visible
{{ 'VISIBLE' | translate }}
</mat-slide-toggle>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Priority</mat-label>
<mat-label>{{ 'PRIORITY' | translate }}</mat-label>
<input
matInput
type="number"
@@ -51,14 +39,14 @@
(blur)="onFieldChange('priority', category()!.priority)"
required
min="0">
<mat-hint>Lower numbers appear first</mat-hint>
<mat-hint>{{ 'PRIORITY_HINT' | translate }}</mat-hint>
@if (category()!.priority < 0) {
<mat-error>Priority cannot be negative</mat-error>
<mat-error>{{ 'PRIORITY_HINT' | translate }}</mat-error>
}
</mat-form-field>
<div class="image-section">
<h3>Image</h3>
<h3>{{ 'IMAGE' | translate }}</h3>
@if (category()!.img) {
<div class="image-preview">
@@ -70,7 +58,7 @@
<div class="upload-option">
<label for="file-upload" class="upload-label">
<mat-icon>upload_file</mat-icon>
Upload Image
{{ 'UPLOAD_IMAGE' | translate }}
</label>
<input
id="file-upload"
@@ -81,7 +69,7 @@
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Or enter image URL</mat-label>
<mat-label>{{ 'IMAGE_URL' | translate }}</mat-label>
<input
matInput
[value]="category()!.img || ''"
@@ -92,8 +80,8 @@
<div class="subcategories-section">
<div class="section-header">
<h3>Subcategories ({{ category()!.subcategories?.length || 0 }})</h3>
<button mat-mini-fab color="primary" (click)="addSubcategory()" matTooltip="Add Subcategory">
<h3>{{ 'SUBCATEGORIES' | translate }} ({{ category()!.subcategories?.length || 0 }})</h3>
<button mat-mini-fab color="primary" (click)="addSubcategory()" [matTooltip]="'ADD_SUBCATEGORY' | translate">
<mat-icon>add</mat-icon>
</button>
</div>
@@ -103,7 +91,7 @@
@for (sub of category()!.subcategories; track sub.id) {
<mat-list-item (click)="openSubcategory(sub.id)">
<span matListItemTitle>{{ sub.name }}</span>
<span matListItemLine>Priority: {{ sub.priority }}</span>
<span matListItemLine>{{ 'PRIORITY' | translate }}: {{ sub.priority }}</span>
<button mat-icon-button matListItemMeta>
<mat-icon>chevron_right</mat-icon>
</button>
@@ -111,9 +99,18 @@
}
</mat-list>
} @else {
<p class="empty-state">No subcategories yet</p>
<p class="empty-state">{{ 'NO_SUBCATEGORIES' | translate }}</p>
}
</div>
<div class="translations-section">
<h3>{{ 'TRANSLATIONS' | translate }}</h3>
<p class="hint">{{ 'TRANSLATIONS_HINT' | translate }}</p>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'NAME_TRANSLATED' | translate }}</mat-label>
<input matInput [(ngModel)]="ruName" (blur)="saveRuName(ruName)" [placeholder]="'NAME_TRANSLATED' | translate">
</mat-form-field>
</div>
</div>
}
</div>

View File

@@ -10,12 +10,15 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { ApiService } from '../../services';
import { Category } from '../../models';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-category-editor',
@@ -32,7 +35,9 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
MatSnackBarModule,
MatListModule,
MatDialogModule,
LoadingSkeletonComponent
MatTooltipModule,
LoadingSkeletonComponent,
TranslatePipe
],
templateUrl: './category-editor.component.html',
styleUrls: ['./category-editor.component.scss']
@@ -44,12 +49,16 @@ export class CategoryEditorComponent implements OnInit {
categoryId = signal<string>('');
projectId = signal<string>('');
/** Local buffer for the Russian translation of the category name */
ruName = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
private snackBar: MatSnackBar,
private dialog: MatDialog
private dialog: MatDialog,
public lang: LanguageService
) {}
ngOnInit() {
@@ -70,6 +79,7 @@ export class CategoryEditorComponent implements OnInit {
this.apiService.getCategory(this.categoryId()).subscribe({
next: (category) => {
this.category.set(category);
this.ruName = category.translations?.['ru']?.name || '';
this.loading.set(false);
},
error: (err) => {
@@ -80,6 +90,14 @@ export class CategoryEditorComponent implements OnInit {
});
}
saveRuName(value: string) {
const cat = this.category();
if (!cat) return;
cat.translations = cat.translations || {};
cat.translations['ru'] = { ...(cat.translations['ru'] || {}), name: value };
this.onFieldChange('translations' as any, cat.translations);
}
onFieldChange(field: keyof Category, value: any) {
this.saving.set(true);
this.apiService.queueSave('category', this.categoryId(), field, value);

View File

@@ -7,15 +7,15 @@
<button mat-icon-button (click)="goBack()">
<mat-icon>close</mat-icon>
</button>
<h2>Edit Item</h2>
<h2>{{ 'EDIT_ITEM' | translate }}</h2>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
@if (saving()) {
<span class="save-indicator">Saving...</span>
<span class="save-indicator">{{ 'SAVING' | translate }}</span>
}
<button mat-raised-button color="accent" (click)="previewInMarketplace()">
<mat-icon>open_in_new</mat-icon>
Preview
{{ 'PREVIEW' | translate }}
</button>
<button mat-icon-button color="warn" (click)="deleteItem()">
<mat-icon>delete</mat-icon>
@@ -25,10 +25,10 @@
<mat-tab-group class="editor-tabs">
<!-- Basic Info Tab -->
<mat-tab label="Basic Info">
<mat-tab [label]="'BASIC_INFO' | translate">
<div class="tab-content">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Name</mat-label>
<mat-label>{{ 'ITEM_NAME' | translate }}</mat-label>
<input
matInput
[(ngModel)]="item()!.name"
@@ -49,23 +49,23 @@
[(ngModel)]="item()!.visible"
(change)="onFieldChange('visible', item()!.visible)"
color="primary">
Visible
{{ 'VISIBLE' | translate }}
</mat-slide-toggle>
</div>
<div class="form-row">
<mat-form-field appearance="outline" class="half-width">
<mat-label>Priority</mat-label>
<mat-label>{{ 'PRIORITY' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="item()!.priority"
(blur)="onFieldChange('priority', item()!.priority)">
<mat-hint>Lower numbers appear first</mat-hint>
<mat-hint>{{ 'PRIORITY_HINT' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="half-width">
<mat-label>Quantity</mat-label>
<mat-label>{{ 'QUANTITY' | translate }}</mat-label>
<input
matInput
type="number"
@@ -81,7 +81,7 @@
<div class="form-row">
<mat-form-field appearance="outline" class="half-width">
<mat-label>Price</mat-label>
<mat-label>{{ 'PRICE' | translate }}</mat-label>
<input
matInput
type="number"
@@ -96,7 +96,7 @@
</mat-form-field>
<mat-form-field appearance="outline" class="half-width">
<mat-label>Currency</mat-label>
<mat-label>{{ 'CURRENCY' | translate }}</mat-label>
<mat-select
[(ngModel)]="item()!.currency"
(selectionChange)="onFieldChange('currency', item()!.currency)">
@@ -108,7 +108,7 @@
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Simple Description</mat-label>
<mat-label>{{ 'SIMPLE_DESCRIPTION' | translate }}</mat-label>
<textarea
matInput
rows="4"
@@ -120,7 +120,7 @@
</mat-tab>
<!-- Images Tab -->
<mat-tab label="Images">
<mat-tab [label]="'IMAGES' | translate">
<div class="tab-content">
<div class="images-section">
<div class="upload-area">
@@ -162,7 +162,7 @@
@if (!item()!.imgs.length) {
<div class="empty-images">
<mat-icon>image</mat-icon>
<p>No images yet</p>
<p>{{ 'NO_IMAGES' | translate }}</p>
</div>
}
</div>
@@ -170,17 +170,17 @@
</mat-tab>
<!-- Tags Tab -->
<mat-tab label="Tags">
<mat-tab [label]="'TAGS' | translate">
<div class="tab-content">
<div class="tags-section">
<div class="add-tag-form">
<mat-form-field appearance="outline" class="tag-input">
<mat-label>Add Tag</mat-label>
<mat-label>{{ 'ADD_TAG' | translate }}</mat-label>
<input
matInput
[(ngModel)]="newTag"
(keyup.enter)="addTag()"
placeholder="e.g. new, sale, featured">
[placeholder]="'TAG_PLACEHOLDER' | translate">
</mat-form-field>
<button mat-raised-button color="primary" (click)="addTag()">
<mat-icon>add</mat-icon>
@@ -202,7 +202,7 @@
@if (!item()!.tags.length) {
<div class="empty-state">
<mat-icon>label</mat-icon>
<p>No tags yet</p>
<p>{{ 'NO_TAGS' | translate }}</p>
</div>
}
</div>
@@ -210,11 +210,11 @@
</mat-tab>
<!-- Badges Tab -->
<mat-tab label="Badges">
<mat-tab [label]="'BADGES' | translate">
<div class="tab-content">
<div class="badges-section">
<h3>Predefined Badges</h3>
<p class="hint">Toggle badges to highlight this item in the marketplace.</p>
<h3>{{ 'PREDEFINED_BADGES' | translate }}</h3>
<p class="hint">{{ 'PREDEFINED_BADGES_HINT' | translate }}</p>
<div class="predefined-badges">
@for (badge of predefinedBadges; track badge.value) {
<button
@@ -230,15 +230,15 @@
}
</div>
<h3 style="margin-top: 1.5rem">Custom Badges</h3>
<h3 style="margin-top: 1.5rem">{{ 'CUSTOM_BADGES' | translate }}</h3>
<div class="add-badge-form">
<mat-form-field appearance="outline" class="badge-input">
<mat-label>Custom Badge</mat-label>
<mat-label>{{ 'ADD_BADGE' | translate }}</mat-label>
<input
matInput
[(ngModel)]="newBadge"
(keyup.enter)="addCustomBadge()"
placeholder="e.g. pre-order">
[placeholder]="'BADGE_PLACEHOLDER' | translate">
</mat-form-field>
<button mat-raised-button color="primary" (click)="addCustomBadge()">
<mat-icon>add</mat-icon>
@@ -260,7 +260,7 @@
@if (!(item()!.badges?.length)) {
<div class="empty-state">
<mat-icon>new_releases</mat-icon>
<p>No badges yet</p>
<p>{{ 'NO_BADGES' | translate }}</p>
</div>
}
</div>
@@ -268,27 +268,27 @@
</mat-tab>
<!-- Detailed Description Tab -->
<mat-tab label="Description">
<mat-tab [label]="'DESCRIPTION' | translate">
<div class="tab-content">
<div class="description-section">
<h3>Key-Value Description Fields</h3>
<p class="hint">Add structured information like color, size, material, etc.</p>
<h3>{{ 'DESCRIPTION' | translate }}</h3>
<p class="hint">{{ 'DESC_HINT' | translate }}</p>
<div class="add-desc-form">
<mat-form-field appearance="outline">
<mat-label>Key</mat-label>
<mat-label>{{ 'DESC_KEY' | translate }}</mat-label>
<input
matInput
[(ngModel)]="newDescKey"
placeholder="e.g. Color">
[placeholder]="'DESC_KEY_PLACEHOLDER' | translate">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Value</mat-label>
<mat-label>{{ 'DESC_VALUE' | translate }}</mat-label>
<input
matInput
[(ngModel)]="newDescValue"
placeholder="e.g. Black">
[placeholder]="'DESC_VALUE_PLACEHOLDER' | translate">
</mat-form-field>
<button
@@ -296,7 +296,7 @@
color="primary"
(click)="addDescriptionField()">
<mat-icon>add</mat-icon>
Add Field
{{ 'ADD_FIELD' | translate }}
</button>
</div>
@@ -304,7 +304,7 @@
@for (field of item()!.description; track $index) {
<div class="desc-field-row">
<mat-form-field appearance="outline">
<mat-label>Key</mat-label>
<mat-label>{{ 'DESC_KEY' | translate }}</mat-label>
<input
matInput
[value]="field.key"
@@ -312,7 +312,7 @@
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Value</mat-label>
<mat-label>{{ 'DESC_VALUE' | translate }}</mat-label>
<input
matInput
[value]="field.value"
@@ -332,7 +332,7 @@
@if (!item()!.description.length) {
<div class="empty-state">
<mat-icon>description</mat-icon>
<p>No description fields yet</p>
<p>{{ 'NO_DESC_FIELDS' | translate }}</p>
</div>
}
</div>
@@ -340,7 +340,7 @@
</mat-tab>
<!-- Comments Tab -->
<mat-tab label="Comments">
<mat-tab [label]="'COMMENTS' | translate">
<div class="tab-content">
<div class="comments-section">
@if (item()!.comments?.length) {
@@ -369,12 +369,62 @@
} @else {
<div class="empty-state">
<mat-icon>comment</mat-icon>
<p>No comments yet</p>
<p>{{ 'NO_COMMENTS' | translate }}</p>
</div>
}
</div>
</div>
</mat-tab>
<!-- Translations Tab -->
<mat-tab [label]="'TRANSLATIONS' | translate">
<div class="tab-content">
<div class="translations-section">
<p class="hint">{{ 'TRANSLATIONS_HINT' | translate }}</p>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'NAME_TRANSLATED' | translate }}</mat-label>
<input matInput [(ngModel)]="ruName" [placeholder]="'NAME_TRANSLATED' | translate">
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'SIMPLE_DESC_TRANSLATED' | translate }}</mat-label>
<textarea matInput rows="3" [(ngModel)]="ruSimpleDesc"></textarea>
</mat-form-field>
<h3>{{ 'DESC_TRANSLATED' | translate }}</h3>
<div class="desc-fields-list">
@for (field of ruDescFields; track $index) {
<div class="desc-field-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'DESC_KEY_RU' | translate }}</mat-label>
<input matInput [value]="field.key" (blur)="updateRuDescField($index, 'key', $any($event.target).value)">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'DESC_VALUE_RU' | translate }}</mat-label>
<input matInput [value]="field.value" (blur)="updateRuDescField($index, 'value', $any($event.target).value)">
</mat-form-field>
<button mat-icon-button color="warn" (click)="removeRuDescRow($index)">
<mat-icon>delete</mat-icon>
</button>
</div>
}
</div>
<button mat-stroked-button (click)="addRuDescRow()">
<mat-icon>add</mat-icon>
{{ 'ADD_DESC_ROW' | translate }}
</button>
<div style="margin-top: 1.5rem">
<button mat-raised-button color="primary" (click)="saveItemTranslations()">
<mat-icon>save</mat-icon>
{{ 'SAVE' | translate }}
</button>
</div>
</div>
</div>
</mat-tab>
</mat-tab-group>
}
</div>

View File

@@ -19,6 +19,8 @@ import { ValidationService } from '../../services/validation.service';
import { Item, ItemDescriptionField, Subcategory } from '../../models';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-item-editor',
@@ -38,7 +40,8 @@ import { LoadingSkeletonComponent } from '../../components/loading-skeleton/load
MatTabsModule,
MatDialogModule,
DragDropModule,
LoadingSkeletonComponent
LoadingSkeletonComponent,
TranslatePipe
],
templateUrl: './item-editor.component.html',
styleUrls: ['./item-editor.component.scss']
@@ -57,6 +60,11 @@ export class ItemEditorComponent implements OnInit {
newDescValue = '';
uploadingImages = signal<boolean>(false);
/** Russian translation buffers */
ruName = '';
ruSimpleDesc = '';
ruDescFields: ItemDescriptionField[] = [];
currencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
predefinedBadges: { label: string; value: string; color: string }[] = [
@@ -77,7 +85,8 @@ export class ItemEditorComponent implements OnInit {
private apiService: ApiService,
private snackBar: MatSnackBar,
private dialog: MatDialog,
private validationService: ValidationService
private validationService: ValidationService,
public lang: LanguageService
) {}
ngOnInit() {
@@ -98,6 +107,11 @@ export class ItemEditorComponent implements OnInit {
this.apiService.getItem(this.itemId()).subscribe({
next: (item) => {
this.item.set(item);
// Initialise Russian translation buffers
const ru = item.translations?.['ru'];
this.ruName = ru?.name || '';
this.ruSimpleDesc = ru?.simpleDescription || '';
this.ruDescFields = ru?.description ? [...ru.description] : [];
// Load subcategory to get allowed description fields
this.loadSubcategory(item.subcategoryId);
},
@@ -252,6 +266,34 @@ export class ItemEditorComponent implements OnInit {
this.onFieldChange('badges', badges);
}
// Russian translation methods
saveItemTranslations() {
const currentItem = this.item();
if (!currentItem) return;
currentItem.translations = currentItem.translations || {};
currentItem.translations['ru'] = {
name: this.ruName,
simpleDescription: this.ruSimpleDesc,
description: this.ruDescFields.filter(f => f.key.trim() || f.value.trim()),
};
this.onFieldChange('translations' as any, currentItem.translations);
this.snackBar.open(this.lang.t('TRANSLATION_SAVED'), '', { duration: 2000 });
}
addRuDescRow() {
this.ruDescFields = [...this.ruDescFields, { key: '', value: '' }];
}
removeRuDescRow(index: number) {
this.ruDescFields = this.ruDescFields.filter((_, i) => i !== index);
}
updateRuDescField(index: number, field: 'key' | 'value', value: string) {
this.ruDescFields = this.ruDescFields.map((f, i) =>
i === index ? { ...f, [field]: value } : f
);
}
// Description fields handling
addDescriptionField() {
if (!this.newDescKey.trim() || !this.newDescValue.trim()) return;

View File

@@ -6,12 +6,12 @@
</button>
<span class="preview-label">
<mat-icon>visibility</mat-icon>
Preview
{{ 'PREVIEW' | translate }}
</span>
<span class="spacer"></span>
<button mat-raised-button color="primary" (click)="openEdit()">
<mat-icon>edit</mat-icon>
Edit Item
{{ 'EDIT_ITEM' | translate }}
</button>
</div>
@@ -33,13 +33,13 @@
} @else {
<div class="no-image">
<mat-icon>image</mat-icon>
<span>No image</span>
<span>{{ 'NO_IMAGES' | translate }}</span>
</div>
}
<!-- Out of stock overlay -->
@if (item.quantity === 0) {
<div class="oos-banner">Out of Stock</div>
<div class="oos-banner">{{ 'OUT_OF_STOCK_BANNER' | translate }}</div>
}
<!-- Badges overlay -->
@@ -76,12 +76,12 @@
@if (item.quantity > 0) {
<span class="in-stock">
<mat-icon>check_circle</mat-icon>
In stock ({{ item.quantity }})
{{ 'IN_STOCK' | translate }} ({{ item.quantity }})
</span>
} @else {
<span class="out-of-stock">
<mat-icon>cancel</mat-icon>
Out of stock
{{ 'OUT_OF_STOCK' | translate }}
</span>
}
</div>
@@ -108,7 +108,7 @@
<!-- Badges -->
@if (item.badges?.length) {
<div class="section">
<span class="section-label">Badges</span>
<span class="section-label">{{ 'SECTION_BADGES' | translate }}</span>
<div class="badges-row">
@for (badge of item.badges || []; track badge) {
<span class="badge-chip" [style.background-color]="badgeColor(badge)">{{ badge }}</span>
@@ -120,7 +120,7 @@
<!-- Tags -->
@if (item?.tags?.length) {
<div class="section">
<span class="section-label">Tags</span>
<span class="section-label">{{ 'SECTION_TAGS' | translate }}</span>
<div class="tags-row">
@for (tag of item.tags; track tag) {
<mat-chip>{{ tag }}</mat-chip>
@@ -131,12 +131,12 @@
<!-- Meta -->
<div class="meta-row">
<span>Priority: {{ item.priority }}</span>
<span>{{ 'PRIORITY' | translate }}: {{ item.priority }}</span>
<span>
<mat-icon [class.icon-visible]="item.visible" [class.icon-hidden]="!item.visible">
{{ item.visible ? 'visibility' : 'visibility_off' }}
</mat-icon>
{{ item.visible ? 'Visible' : 'Hidden' }}
{{ item.visible ? ('VISIBLE' | translate) : ('HIDDEN' | translate) }}
</span>
</div>
</div>
@@ -145,8 +145,8 @@
} @else {
<div class="empty-state">
<mat-icon>error_outline</mat-icon>
<p>Item not found</p>
<button mat-button (click)="goBack()">Go back</button>
<p>{{ 'ITEM_NOT_FOUND' | translate }}</p>
<button mat-button (click)="goBack()">{{ 'GO_BACK' | translate }}</button>
</div>
}
</div>

View File

@@ -9,6 +9,8 @@ import { MatDividerModule } from '@angular/material/divider';
import { ApiService } from '../../services';
import { Item } from '../../models';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
interface BadgeDef { value: string; color: string; }
@@ -23,6 +25,7 @@ interface BadgeDef { value: string; color: string; }
MatProgressSpinnerModule,
MatDividerModule,
LoadingSkeletonComponent,
TranslatePipe,
],
templateUrl: './item-preview.component.html',
styleUrls: ['./item-preview.component.scss']
@@ -48,6 +51,7 @@ export class ItemPreviewComponent implements OnInit {
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
public lang: LanguageService
) {}
ngOnInit() {

View File

@@ -12,36 +12,36 @@
<div class="filters-bar">
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search items</mat-label>
<mat-label>{{ 'SEARCH_ITEMS' | translate }}</mat-label>
<input
matInput
[(ngModel)]="searchQuery"
(keyup.enter)="onSearch()"
placeholder="Search by name...">
[placeholder]="'SEARCH_PLACEHOLDER' | translate">
<button mat-icon-button matSuffix (click)="onSearch()">
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
<mat-form-field appearance="outline" class="filter-field">
<mat-label>Visibility</mat-label>
<mat-label>{{ 'VISIBILITY' | translate }}</mat-label>
<mat-select [(ngModel)]="visibilityFilter" (selectionChange)="onFilterChange()">
<mat-option [value]="undefined">All</mat-option>
<mat-option [value]="true">Visible</mat-option>
<mat-option [value]="false">Hidden</mat-option>
<mat-option [value]="undefined">{{ 'ALL' | translate }}</mat-option>
<mat-option [value]="true">{{ 'VISIBLE' | translate }}</mat-option>
<mat-option [value]="false">{{ 'HIDDEN' | translate }}</mat-option>
</mat-select>
</mat-form-field>
@if (selectedItems().size > 0) {
<div class="bulk-actions">
<span class="selection-count">{{ selectedItems().size }} selected</span>
<span class="selection-count">{{ selectedItems().size }} {{ 'SELECTED' | translate }}</span>
<button mat-raised-button (click)="bulkToggleVisibility(true)">
<mat-icon>visibility</mat-icon>
Show
{{ 'SHOW' | translate }}
</button>
<button mat-raised-button (click)="bulkToggleVisibility(false)">
<mat-icon>visibility_off</mat-icon>
Hide
{{ 'HIDE' | translate }}
</button>
</div>
}
@@ -98,11 +98,11 @@
<div class="item-details">
<span class="price">{{ item.price }} {{ item.currency }}</span>
<span class="quantity">Qty: {{ item.quantity }}</span>
<span class="quantity">{{ 'QTY' | translate }}: {{ item.quantity }}</span>
</div>
<div class="item-meta">
<span class="priority">Priority: {{ item.priority }}</span>
<span class="priority">{{ 'PRIORITY' | translate }}: {{ item.priority }}</span>
<mat-icon [class.visible]="item.visible" [class.hidden]="!item.visible">
{{ item.visible ? 'visibility' : 'visibility_off' }}
</mat-icon>
@@ -126,20 +126,20 @@
@if (loading()) {
<div class="loading-more">
<mat-spinner diameter="40"></mat-spinner>
<span>Loading more items...</span>
<span>{{ 'LOADING_MORE' | translate }}</span>
</div>
}
@if (!hasMore() && items().length > 0) {
<div class="end-message">
No more items to load
{{ 'NO_MORE_ITEMS' | translate }}
</div>
}
@if (!loading() && items().length === 0) {
<div class="empty-state">
<mat-icon>inventory_2</mat-icon>
<p>No items found</p>
<p>{{ 'NO_ITEMS_FOUND' | translate }}</p>
</div>
}

View File

@@ -17,6 +17,8 @@ import { ApiService } from '../../services';
import { Item } from '../../models';
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-items-list',
@@ -34,7 +36,8 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
MatSelectModule,
MatToolbarModule,
MatSnackBarModule,
MatDialogModule
MatDialogModule,
TranslatePipe
],
templateUrl: './items-list.component.html',
styleUrls: ['./items-list.component.scss']
@@ -60,7 +63,8 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
private router: Router,
private apiService: ApiService,
private snackBar: MatSnackBar,
private dialog: MatDialog
private dialog: MatDialog,
public lang: LanguageService
) {}
ngOnInit() {

View File

@@ -4,13 +4,18 @@
<mat-icon>arrow_back</mat-icon>
</button>
<span>{{ project()?.displayName || projectId() }}</span>
<span style="flex: 1"></span>
<div class="lang-toggle">
<button [class.active]="lang.currentLang() === 'en'" (click)="lang.setLang('en')">EN</button>
<button [class.active]="lang.currentLang() === 'ru'" (click)="lang.setLang('ru')">RU</button>
</div>
</mat-toolbar>
<mat-sidenav-container class="sidenav-container">
<mat-sidenav mode="side" opened class="categories-sidebar">
<div class="sidebar-header">
<h2>Categories</h2>
<button mat-mini-fab color="primary" (click)="addCategory()" matTooltip="Add Category">
<h2>{{ 'CATEGORIES' | translate }}</h2>
<button mat-mini-fab color="primary" (click)="addCategory()" [matTooltip]="'ADD_CATEGORY' | translate">
<mat-icon>add</mat-icon>
</button>
</div>
@@ -39,7 +44,7 @@
<button
mat-icon-button
(click)="addSubcategory(node, $event)"
matTooltip="Add Subcategory"
[matTooltip]="'ADD_SUBCATEGORY' | translate"
color="accent">
<mat-icon>add</mat-icon>
</button>
@@ -48,14 +53,14 @@
[checked]="node.visible"
(change)="toggleVisibility(node, $event)"
color="primary"
matTooltip="Toggle Visibility">
[matTooltip]="'TOGGLE_VISIBILITY' | translate">
</mat-slide-toggle>
<button mat-icon-button (click)="editNode(node, $event)" color="primary" matTooltip="Edit">
<button mat-icon-button (click)="editNode(node, $event)" color="primary" [matTooltip]="'EDIT' | translate">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button (click)="deleteCategory(node, $event)" color="warn" matTooltip="Delete">
<button mat-icon-button (click)="deleteCategory(node, $event)" color="warn" [matTooltip]="'DELETE' | translate">
<mat-icon>delete</mat-icon>
</button>
</div>
@@ -77,8 +82,8 @@
@if (!hasActiveRoute()) {
<div class="welcome-message">
<mat-icon style="font-size: 64px; width: 64px; height: 64px; color: #1976d2;">dashboard</mat-icon>
<h2>Welcome to {{ project()?.displayName || 'Project' }} Backoffice</h2>
<p>Select a category or subcategory from the sidebar to start editing.</p>
<h2>{{ 'WELCOME_TO' | translate }} {{ project()?.displayName || 'Project' }} {{ 'BACKOFFICE' | translate }}</h2>
<p>{{ 'SELECT_FROM_SIDEBAR' | translate }}</p>
</div>
}
</div>
@@ -109,7 +114,7 @@
<button
mat-icon-button
(click)="addSubcategory(subNode, $event)"
matTooltip="Add Subcategory"
[matTooltip]="'ADD_SUBCATEGORY' | translate"
color="accent">
<mat-icon>add</mat-icon>
</button>
@@ -119,7 +124,7 @@
<button
mat-icon-button
(click)="viewItems(subNode, $event)"
matTooltip="View Items">
[matTooltip]="'VIEW_ITEMS' | translate">
<mat-icon>list</mat-icon>
</button>
}
@@ -128,14 +133,14 @@
[checked]="subNode.visible"
(change)="toggleVisibility(subNode, $event)"
color="primary"
matTooltip="Toggle Visibility">
[matTooltip]="'TOGGLE_VISIBILITY' | translate">
</mat-slide-toggle>
<button mat-icon-button (click)="editNode(subNode, $event)" color="primary" matTooltip="Edit">
<button mat-icon-button (click)="editNode(subNode, $event)" color="primary" [matTooltip]="'EDIT' | translate">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button (click)="deleteSubcategory(subNode, $event)" color="warn" matTooltip="Delete">
<button mat-icon-button (click)="deleteSubcategory(subNode, $event)" color="warn" [matTooltip]="'DELETE' | translate">
<mat-icon>delete</mat-icon>
</button>
</div>

View File

@@ -4,6 +4,41 @@
flex-direction: column;
}
// Language toggle in toolbar
.lang-toggle {
display: flex;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 4px;
overflow: hidden;
button {
background: transparent;
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.65);
padding: 4px 10px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 24px;
transition: background 0.15s;
&:first-child {
border-left: none;
}
&.active {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
&:hover:not(.active) {
background: rgba(255, 255, 255, 0.08);
}
}
}
.sidenav-container {
flex: 1;
height: calc(100vh - 64px);

View File

@@ -18,6 +18,8 @@ import { CreateDialogComponent } from '../../components/create-dialog/create-dia
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
import { MatTooltipModule } from '@angular/material/tooltip';
import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
interface CategoryNode {
id: string;
@@ -47,7 +49,8 @@ interface CategoryNode {
MatDialogModule,
MatSnackBarModule,
MatTooltipModule,
LoadingSkeletonComponent
LoadingSkeletonComponent,
TranslatePipe
],
templateUrl: './project-view.component.html',
styleUrls: ['./project-view.component.scss']
@@ -66,7 +69,8 @@ export class ProjectViewComponent implements OnInit {
private apiService: ApiService,
private dialog: MatDialog,
private snackBar: MatSnackBar,
private validationService: ValidationService
private validationService: ValidationService,
public lang: LanguageService
) {}
ngOnInit() {

View File

@@ -1,5 +1,5 @@
<div class="dashboard-container">
<h1>Marketplace Backoffice</h1>
<h1>{{ 'MARKETPLACE_BACKOFFICE' | translate }}</h1>
@if (loading()) {
<div class="loading-container">
@@ -25,7 +25,7 @@
</mat-card-header>
<mat-card-content>
<div class="project-status" [class.active]="project.active">
{{ project.active ? 'Active' : 'Inactive' }}
{{ project.active ? ('ACTIVE' | translate) : ('INACTIVE' | translate) }}
</div>
</mat-card-content>
</mat-card>

View File

@@ -5,11 +5,13 @@ import { MatCardModule } from '@angular/material/card';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ApiService } from '../../services';
import { Project } from '../../models';
import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-projects-dashboard',
standalone: true,
imports: [CommonModule, MatCardModule, MatProgressSpinnerModule],
imports: [CommonModule, MatCardModule, MatProgressSpinnerModule, TranslatePipe],
templateUrl: './projects-dashboard.component.html',
styleUrls: ['./projects-dashboard.component.scss']
})
@@ -21,7 +23,8 @@ export class ProjectsDashboardComponent implements OnInit {
constructor(
private apiService: ApiService,
private router: Router
private router: Router,
public lang: LanguageService
) {}
ngOnInit() {

View File

@@ -7,13 +7,13 @@
<button mat-icon-button (click)="goBack()">
<mat-icon>close</mat-icon>
</button>
<h2>Edit Subcategory</h2>
<h2>{{ 'EDIT_SUBCATEGORY' | translate }}</h2>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
@if (saving()) {
<span class="save-indicator">Saving...</span>
<span class="save-indicator">{{ 'SAVING' | translate }}</span>
}
<button mat-icon-button color="warn" (click)="deleteSubcategory()" matTooltip="Delete Subcategory">
<button mat-icon-button color="warn" (click)="deleteSubcategory()" [matTooltip]="'DELETE_SUBCATEGORY' | translate">
<mat-icon>delete</mat-icon>
</button>
</div>
@@ -21,14 +21,14 @@
<div class="editor-content">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Name</mat-label>
<input
matInput
<mat-label>{{ 'NAME' | translate }}</mat-label>
<input
matInput
[(ngModel)]="subcategory()!.name"
(blur)="onFieldChange('name', subcategory()!.name)"
required>
@if (!subcategory()!.name || subcategory()!.name.trim().length === 0) {
<mat-error>Subcategory name is required</mat-error>
<mat-error>{{ 'NAME' | translate }}</mat-error>
}
</mat-form-field>
@@ -39,7 +39,7 @@
[(ngModel)]="subcategory()!.id"
(blur)="onFieldChange('id', subcategory()!.id)"
required>
<mat-hint>Used for routing - update carefully</mat-hint>
<mat-hint>{{ 'ID' | translate }}</mat-hint>
@if (!subcategory()!.id || subcategory()!.id.trim().length === 0) {
<mat-error>ID is required</mat-error>
}
@@ -50,12 +50,12 @@
[(ngModel)]="subcategory()!.visible"
(change)="onFieldChange('visible', subcategory()!.visible)"
color="primary">
Visible
{{ 'VISIBLE' | translate }}
</mat-slide-toggle>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Priority</mat-label>
<mat-label>{{ 'PRIORITY' | translate }}</mat-label>
<input
matInput
type="number"
@@ -63,14 +63,14 @@
(blur)="onFieldChange('priority', subcategory()!.priority)"
required
min="0">
<mat-hint>Lower numbers appear first</mat-hint>
<mat-hint>{{ 'PRIORITY_HINT' | translate }}</mat-hint>
@if (subcategory()!.priority < 0) {
<mat-error>Priority cannot be negative</mat-error>
<mat-error>{{ 'PRIORITY_HINT' | translate }}</mat-error>
}
</mat-form-field>
<div class="image-section">
<h3>Image</h3>
<h3>{{ 'IMAGE' | translate }}</h3>
@if (subcategory()!.img) {
<div class="image-preview">
@@ -82,7 +82,7 @@
<div class="upload-option">
<label for="file-upload" class="upload-label">
<mat-icon>upload_file</mat-icon>
Upload Image
{{ 'UPLOAD_IMAGE' | translate }}
</label>
<input
id="file-upload"
@@ -93,7 +93,7 @@
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Or enter image URL</mat-label>
<mat-label>{{ 'IMAGE_URL' | translate }}</mat-label>
<input
matInput
[value]="subcategory()!.img || ''"
@@ -106,15 +106,24 @@
@if (subcategory()!.subcategories?.length) {
<p class="no-items-note">
<mat-icon>account_tree</mat-icon>
This subcategory has child subcategories — items can only be added to leaf nodes.
{{ 'SUBCATEGORIES' | translate }}
</p>
} @else {
<button mat-raised-button color="primary" (click)="viewItems()">
<mat-icon>{{ subcategory()!.hasItems ? 'list' : 'add' }}</mat-icon>
{{ subcategory()!.hasItems ? 'View Items (' + (subcategory()!.itemCount || 0) + ')' : 'Add Items' }}
{{ subcategory()!.hasItems ? (('VIEW_ITEMS' | translate) + ' (' + (subcategory()!.itemCount || 0) + ')') : ('ADD_SUBCATEGORY' | translate) }}
</button>
}
</div>
<div class="translations-section">
<h3>{{ 'TRANSLATIONS' | translate }}</h3>
<p class="hint">{{ 'TRANSLATIONS_HINT' | translate }}</p>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'NAME_TRANSLATED' | translate }}</mat-label>
<input matInput [(ngModel)]="ruName" (blur)="saveRuName(ruName)" [placeholder]="'NAME_TRANSLATED' | translate">
</mat-form-field>
</div>
</div>
}
</div>

View File

@@ -9,11 +9,14 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { ApiService } from '../../services';
import { Subcategory } from '../../models';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
selector: 'app-subcategory-editor',
@@ -29,7 +32,9 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
MatProgressSpinnerModule,
MatSnackBarModule,
MatDialogModule,
LoadingSkeletonComponent
MatTooltipModule,
LoadingSkeletonComponent,
TranslatePipe
],
templateUrl: './subcategory-editor.component.html',
styleUrls: ['./subcategory-editor.component.scss']
@@ -41,12 +46,16 @@ export class SubcategoryEditorComponent implements OnInit {
subcategoryId = signal<string>('');
projectId = signal<string>('');
/** Local buffer for the Russian translation of the subcategory name */
ruName = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
private snackBar: MatSnackBar,
private dialog: MatDialog
private dialog: MatDialog,
public lang: LanguageService
) {}
ngOnInit() {
@@ -67,6 +76,7 @@ export class SubcategoryEditorComponent implements OnInit {
this.apiService.getSubcategory(this.subcategoryId()).subscribe({
next: (subcategory) => {
this.subcategory.set(subcategory);
this.ruName = subcategory.translations?.['ru']?.name || '';
this.loading.set(false);
},
error: (err) => {
@@ -77,6 +87,14 @@ export class SubcategoryEditorComponent implements OnInit {
});
}
saveRuName(value: string) {
const sub = this.subcategory();
if (!sub) return;
sub.translations = sub.translations || {};
sub.translations['ru'] = { ...(sub.translations['ru'] || {}), name: value };
this.onFieldChange('translations' as any, sub.translations);
}
onFieldChange(field: keyof Subcategory, value: any) {
this.saving.set(true);
this.apiService.queueSave('subcategory', this.subcategoryId(), field, value);

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform, inject } from '@angular/core';
import { LanguageService } from '../services/language.service';
/**
* Pure:false pipe so it re-evaluates on every change detection cycle,
* which fires whenever the currentLang signal changes (triggered by the toggle click).
*
* Usage: {{ 'CATEGORIES' | translate }}
*/
@Pipe({
name: 'translate',
standalone: true,
pure: false,
})
export class TranslatePipe implements PipeTransform {
private lang = inject(LanguageService);
transform(key: string): string {
return this.lang.t(key);
}
}

View File

@@ -0,0 +1,34 @@
import { Injectable, signal } from '@angular/core';
import { TRANSLATIONS } from '../i18n/translations';
export type SupportedLang = 'en' | 'ru';
export const SUPPORTED_LANGS: { code: SupportedLang; label: string }[] = [
{ code: 'en', label: 'EN' },
{ code: 'ru', label: 'RU' },
];
// All UI strings live in src/app/i18n/translations.ts
@Injectable({ providedIn: 'root' })
export class LanguageService {
currentLang = signal<SupportedLang>('ru');
toggle() {
this.currentLang.set(this.currentLang() === 'en' ? 'ru' : 'en');
}
setLang(lang: SupportedLang) {
this.currentLang.set(lang);
}
t(key: string): string {
const lang = this.currentLang();
return TRANSLATIONS[lang]?.[key] ?? TRANSLATIONS['en']?.[key] ?? key;
}
/** Returns the secondary content language (the one to translate INTO). */
get contentTranslationLang(): SupportedLang {
return this.currentLang() === 'en' ? 'ru' : 'en';
}
}