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

81
API.md
View File

@@ -3,6 +3,8 @@
Endpoint reference for the Marketplace Backoffice. Endpoint reference for the Marketplace Backoffice.
Base URL: `https://your-api-domain.com/api` Base URL: `https://your-api-domain.com/api`
> 🇷🇺 Документация на русском языке: [API.ru.md](./API.ru.md)
--- ---
## Projects ## Projects
@@ -237,6 +239,16 @@ Response 200:
{ "key": "Storage", "value": "256GB" } { "key": "Storage", "value": "256GB" }
], ],
"subcategoryId": "sub1", "subcategoryId": "sub1",
"translations": {
"ru": {
"name": "iPhone 15 Про",
"simpleDescription": "Последний iPhone...",
"description": [
{ "key": "Цвет", "value": "Чёрный" },
{ "key": "Память", "value": "256 ГБ" }
]
}
},
"comments": [ "comments": [
{ {
"id": "c1", "id": "c1",
@@ -280,7 +292,16 @@ Body:
"simpleDescription": "Short description", "simpleDescription": "Short description",
"description": [ "description": [
{ "key": "Size", "value": "Large" } { "key": "Size", "value": "Large" }
] ],
"translations": { // optional - localized content for marketplace
"ru": {
"name": "Новый товар",
"simpleDescription": "Краткое описание",
"description": [
{ "key": "Размер", "value": "Большой" }
]
}
}
} }
Response 201: (created item object) Response 201: (created item object)
@@ -369,6 +390,7 @@ Response 201:
- `badges`: optional string array. Predefined values with UI colors: `new`, `sale`, `exclusive`, `hot`, `limited`, `bestseller`, `featured`. Custom strings are also allowed. - `badges`: optional string array. Predefined values with UI colors: `new`, `sale`, `exclusive`, `hot`, `limited`, `bestseller`, `featured`. Custom strings are also allowed.
- `imgs`: always send the **complete** array on update, not individual images. - `imgs`: always send the **complete** array on update, not individual images.
- `description`: array of `{ key, value }` pairs - free-form attributes per item. - `description`: array of `{ key, value }` pairs - free-form attributes per item.
- `translations`: optional object keyed by language code (`"ru"`, `"en"`, etc.) — each value may contain `name`, `simpleDescription`, `description[]`. The marketplace frontend should use these when rendering in the corresponding language, falling back to the default fields if a translation is absent.
- Auto-save from the backoffice fires `PATCH` with a single field every ~500 ms. - Auto-save from the backoffice fires `PATCH` with a single field every ~500 ms.
--- ---
@@ -423,3 +445,60 @@ The `id` field on subcategories is editable via `PATCH` to allow renaming slugs.
| Category | all subcategories (recursive) and their items | | Category | all subcategories (recursive) and their items |
| Subcategory | all nested subcategories (recursive) and their items | | Subcategory | all nested subcategories (recursive) and their items |
| Item | nothing else | | Item | nothing else |
---
## Internationalization (i18n)
The API supports localized content for items and categories via the `translations` field.
### `translations` object structure
Any entity that supports translations accepts the same nested shape:
```json
{
"translations": {
"ru": {
"name": "Название",
"simpleDescription": "Краткое описание",
"description": [
{ "key": "Цвет", "value": "Чёрный" }
]
}
}
}
```
- Keys are ISO 639-1 language codes (`"ru"`, `"en"`, etc.).
- All sub-fields are optional — omit what you don't need.
- Currently supported in the backoffice: `ru` (Russian).
### Requesting a Specific Language
Pass `?lang=ru` to any GET endpoint. The backend will:
1. Return the default top-level fields as-is.
2. Merge the matching `translations[lang]` values into the response, overwriting the default fields.
3. Fall back gracefully to the default language if no translation exists for the requested lang.
```
GET /api/items/:itemId?lang=ru
GET /api/subcategories/:subcategoryId/items?lang=ru
```
Alternatively, you can use the `Accept-Language` header:
```
Accept-Language: ru
```
### Fallback Behaviour
| Scenario | Returned value |
|---|---|
| Translation exists for requested lang | Translated value |
| Translation missing for requested lang | Default (base) field value |
| `lang` param omitted | Default (base) field value |
> 🇷🇺 See [API.ru.md](./API.ru.md) for full documentation in Russian.

503
API.ru.md Normal file
View File

@@ -0,0 +1,503 @@
# Документация API
Справочник эндпоинтов для бэкофиса маркетплейса.
Базовый URL: `https://your-api-domain.com/api`
> 🇬🇧 English documentation: [API.md](./API.md)
---
## Проекты
### Получить все проекты
```
GET /api/projects
Ответ 200:
[
{
"id": "dexar",
"name": "dexar",
"displayName": "Dexar Marketplace",
"active": true,
"logoUrl": "https://..."
}
]
```
---
## Категории
### Получить категории проекта
```
GET /api/projects/:projectId/categories
Ответ 200:
[
{
"id": "cat1",
"name": "Электроника",
"visible": true,
"priority": 1,
"img": "https://...",
"projectId": "dexar",
"subcategories": [ ...Subcategory[] ],
"translations": {
"ru": { "name": "Электроника" }
}
}
]
```
### Получить категорию по ID
```
GET /api/categories/:categoryId
Ответ 200: (объект категории с вложенными подкатегориями)
```
### Создать категорию
```
POST /api/projects/:projectId/categories
Тело запроса:
{
"name": "Новая категория", // обязательно
"visible": true,
"priority": 10,
"img": "https://...",
"translations": { // опционально
"ru": { "name": "Новая категория" }
}
}
Ответ 201: (созданный объект категории)
```
### Обновить категорию
```
PATCH /api/categories/:categoryId
Тело запроса: (любое подмножество полей)
{
"name": "Обновлённое название",
"visible": false,
"priority": 5,
"translations": {
"ru": { "name": "Обновлённое название" }
}
}
Ответ 200: (обновлённый объект категории)
```
### Удалить категорию
```
DELETE /api/categories/:categoryId
Ответ 204 No Content
Примечание: каскадно удаляет все подкатегории и товары внутри
```
---
## Подкатегории
Подкатегории **рекурсивны** — вложенность неограничена. Единственное ограничение:
**подкатегория с товарами не может иметь дочерних подкатегорий** (и наоборот).
### Объект подкатегории
```json
{
"id": "sub1",
"name": "Смартфоны",
"visible": true,
"priority": 1,
"img": "https://...",
"categoryId": "cat1", // всегда ID корневой категории
"parentId": "cat1", // ID прямого родителя (категория или подкатегория)
"itemCount": 15,
"hasItems": true,
"subcategories": [], // дочерние подкатегории (пусто, если hasItems = true)
"translations": {
"ru": { "name": "Смартфоны" }
}
}
```
> `categoryId` — всегда ID **корневой категории** этого поддерева.
> `parentId` — ID **прямого родителя**: может быть ID категории или подкатегории.
---
### Получить подкатегории категории
```
GET /api/categories/:categoryId/subcategories
Ответ 200: Subcategory[] (вложенные подкатегории рекурсивно заполнены)
```
### Получить подкатегорию по ID
```
GET /api/subcategories/:subcategoryId
Ответ 200: объект подкатегории (с вложенными подкатегориями при наличии)
```
### Создать подкатегорию в категории (уровень 1)
```
POST /api/categories/:categoryId/subcategories
Тело запроса:
{
"id": "custom-id", // опционально, генерируется автоматически (используется в URL)
"name": "Смартфоны", // обязательно
"visible": true,
"priority": 10,
"translations": {
"ru": { "name": "Смартфоны" }
}
}
Ответ 201: (созданный объект подкатегории)
Ошибка 400: если категория не существует
```
### Создать подкатегорию в подкатегории (уровень 2+, вложенная)
```
POST /api/subcategories/:parentSubcategoryId/subcategories
Тело запроса:
{
"id": "custom-id", // опционально
"name": "Apple", // обязательно
"visible": true,
"priority": 10
}
Ответ 201: (созданный объект подкатегории)
Ошибка 400: если родительская подкатегория содержит товары (hasItems = true)
Ошибка 404: если родительская подкатегория не найдена
```
### Обновить подкатегорию
```
PATCH /api/subcategories/:subcategoryId
Тело запроса: (любое подмножество полей)
{
"id": "new-slug", // ID редактируется — используется в URL маркетплейса
"name": "Новое название",
"visible": false,
"priority": 3,
"translations": {
"ru": { "name": "Новое название" }
}
}
Ответ 200: (обновлённый объект подкатегории)
```
### Удалить подкатегорию
```
DELETE /api/subcategories/:subcategoryId
Ответ 204 No Content
Примечание: каскадно удаляет все вложенные подкатегории и товары
```
---
## Товары
Товары всегда принадлежат **самой глубокой подкатегории** в иерархии (листовой узел).
Подкатегория с хотя бы одним товаром имеет `hasItems: true` и не может принимать дочерние подкатегории.
### Получить товары (с пагинацией)
```
GET /api/subcategories/:subcategoryId/items
Query-параметры:
page number (по умолчанию: 1)
limit number (по умолчанию: 20)
search string опционально — фильтр по названию (без учёта регистра)
visible boolean опционально — фильтр по видимости
tags string опционально — теги через запятую
lang string опционально — код языка (en | ru); влияет на поля names/descriptions в ответе
Ответ 200:
{
"items": [
{
"id": "item1",
"name": "iPhone 15 Pro",
"visible": true,
"priority": 1,
"quantity": 50,
"price": 1299,
"currency": "USD",
"imgs": ["https://...", "https://..."],
"tags": ["new", "featured"],
"badges": ["new", "exclusive"],
"simpleDescription": "Последний iPhone...",
"description": [
{ "key": "Цвет", "value": "Чёрный" },
{ "key": "Память", "value": "256 ГБ" }
],
"subcategoryId": "sub1",
"translations": {
"ru": {
"name": "iPhone 15 Pro",
"simpleDescription": "Последний iPhone с корпусом из титана",
"description": [
{ "key": "Цвет", "value": "Чёрный" },
{ "key": "Память", "value": "256 ГБ" }
]
}
},
"comments": [
{
"id": "c1",
"text": "Отличный товар!",
"author": "Иван",
"stars": 5,
"createdAt": "2024-01-10T10:30:00Z"
}
]
}
],
"total": 150,
"page": 1,
"limit": 20,
"hasMore": true
}
```
### Получить товар по ID
```
GET /api/items/:itemId
Query-параметры:
lang string опционально (en | ru)
Ответ 200: (полный объект товара)
```
### Создать товар
```
POST /api/subcategories/:subcategoryId/items
Тело запроса:
{
"name": "Новый товар", // обязательно
"visible": true,
"priority": 10,
"quantity": 100,
"price": 999,
"currency": "USD", // USD | EUR | RUB | GBP | UAH
"imgs": ["https://..."],
"tags": ["new"],
"badges": ["new", "exclusive"], // опционально — стандартные или свои метки
"simpleDescription": "Краткое описание",
"description": [
{ "key": "Размер", "value": "Большой" }
],
"translations": { // опционально
"ru": {
"name": "Новый товар",
"simpleDescription": "Краткое описание",
"description": [
{ "key": "Размер", "value": "Большой" }
]
}
}
}
Ответ 201: (созданный объект товара)
Побочный эффект: устанавливает hasItems = true на подкатегории.
Подкатегория больше не может принимать дочерние подкатегории.
```
### Обновить товар
```
PATCH /api/items/:itemId
Тело запроса: (любое подмножество полей)
{
"name": "Новое название",
"price": 899,
"quantity": 80,
"visible": false,
"translations": {
"ru": { "name": "Новое название" }
}
}
Ответ 200: (обновлённый объект товара)
```
**Обновление изображений — всегда передавай полный массив:**
```
PATCH /api/items/:itemId
Тело запроса:
{
"imgs": ["https://new1.jpg", "https://new2.jpg"]
}
Ответ 200: (обновлённый объект товара)
```
### Удалить товар
```
DELETE /api/items/:itemId
Ответ 204 No Content
Побочный эффект: если в подкатегории не осталось товаров — hasItems становится false.
Подкатегория снова может принимать дочерние подкатегории.
```
### Массовое обновление товаров
```
PATCH /api/items/bulk
Тело запроса:
{
"itemIds": ["item1", "item2", "item3"],
"data": {
"visible": true
}
}
Ответ 204 No Content
```
---
## Загрузка файлов
### Загрузить изображение
```
POST /api/upload
Тело запроса: multipart/form-data
image: File
Ответ 201:
{
"url": "https://cdn.example.com/uploads/abc123.jpg"
}
```
---
## Мультиязычность (i18n)
Поля `name`, `simpleDescription` и `description` поддерживают переводы через объект `translations`.
### Структура объекта переводов
```json
{
"translations": {
"ru": {
"name": "Название на русском",
"simpleDescription": "Описание на русском",
"description": [
{ "key": "Ключ", "value": "Значение" }
]
}
}
}
```
> Основные поля (`name`, `simpleDescription`, `description`) хранятся на **английском** (по умолчанию).
> Переводы хранятся в `translations[langCode].*`.
### Запрос конкретного языка
Передай параметр `?lang=ru` в GET-запросах. Бэкенд должен:
1. Вернуть `translations.ru.*` там, где перевод заполнен.
2. Откатиться к основному полю (английскому), если перевод отсутствует.
```
GET /api/items/:itemId?lang=ru
GET /api/subcategories/:subcategoryId/items?lang=ru&page=1
```
Альтернативно — заголовок `Accept-Language: ru`.
### Поддерживаемые языки
| Код | Язык |
|-----|----------|
| en | Английский (по умолчанию) |
| ru | Русский |
---
## Примечания
- Все ответы — JSON.
- Используй `PATCH` для частичного обновления — передавай только изменяемые поля.
- `priority`: меньшее число — выше в списке.
- Поддерживаемые значения `currency`: `USD`, `EUR`, `RUB`, `GBP`, `UAH`.
- `badges`: необязательный массив строк. Стандартные значения с цветами в интерфейсе: `new`, `sale`, `exclusive`, `hot`, `limited`, `bestseller`, `featured`. Свои строки тоже допустимы.
- `imgs`: при обновлении всегда передавай **полный** массив, не отдельные изображения.
- `description`: массив пар `{ key, value }` — свободные атрибуты товара.
- Автосохранение из бэкофиса отправляет `PATCH` с одним полем каждые ~500 мс.
---
## Бизнес-правила
### Вложенные подкатегории
Иерархия выглядит так:
```
Категория (напр. Электроника)
Подкатегория L1 (напр. Кухня) <- можно добавлять детей ИЛИ товары, не оба
Подкатегория L2 (напр. Большая кухня) <- то же правило
Подкатегория L3 (напр. Духовки) <- если есть товары — это листовой узел
Товары...
```
Правила:
- Категория всегда может принимать новые подкатегории (категории никогда не хранят товары напрямую).
- Подкатегория с товарами (`hasItems: true`) **не может** принимать дочерние подкатегории.
- `POST /api/subcategories/:id/subcategories` на узел с `hasItems: true``400 Bad Request`.
- Подкатегория с дочерними подкатегориями не может принимать товары (товары только в листовых узлах).
- При создании **первого товара** в подкатегории → `hasItems` становится `true`.
- При удалении **последнего товара**`hasItems` становится `false`; дочерние подкатегории снова можно добавлять.
### Структура URL (фронтенд маркетплейса)
Поля `id` подкатегорий и товаров используются напрямую в URL маркетплейса:
```
/{categoryId}/{sub1Id}/{sub2Id}/.../{itemId}
Примеры:
/electronics/smartphones/iphone-15
/electronics/smartphones/apple/iphone-15-pro
/furniture/living-room/sofas/corner-sofa-modelo
```
Поле `id` подкатегорий редактируется через `PATCH` для переименования слагов.
### Комментарии
- `stars` — опционально, целое число от 1 до 5.
- `author` — опционально (анонимные комментарии допустимы).
- `createdAt` — строка в формате ISO 8601.
### Каскадное удаление
| Удаляемый объект | Также удаляется |
|---|---|
| Категория | все подкатегории (рекурсивно) и их товары |
| Подкатегория | все вложенные подкатегории (рекурсивно) и их товары |
| Товар | ничего больше |

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 { export interface Category {
id: string; id: string;
name: string; name: string;
@@ -6,6 +14,8 @@ export interface Category {
img?: string; img?: string;
projectId: string; projectId: string;
subcategories?: Subcategory[]; subcategories?: Subcategory[];
/** Optional translations keyed by language code: { ru: { name: '...' } } */
translations?: { [lang: string]: CategoryTranslation };
} }
export interface Subcategory { export interface Subcategory {
@@ -21,4 +31,6 @@ export interface Subcategory {
itemCount?: number; itemCount?: number;
subcategories?: Subcategory[]; subcategories?: Subcategory[];
hasItems?: boolean; 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 { export interface Item {
id: string; id: string;
name: string; name: string;
@@ -13,6 +22,8 @@ export interface Item {
description: ItemDescriptionField[]; description: ItemDescriptionField[];
subcategoryId: string; subcategoryId: string;
comments?: Comment[]; comments?: Comment[];
/** Optional translations keyed by language code: { ru: { name: '...', simpleDescription: '...', description: [...] } } */
translations?: { [lang: string]: ItemTranslation };
} }
export interface ItemDescriptionField { export interface ItemDescriptionField {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,41 @@
flex-direction: column; 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 { .sidenav-container {
flex: 1; flex: 1;
height: calc(100vh - 64px); 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 { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component'; import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
interface CategoryNode { interface CategoryNode {
id: string; id: string;
@@ -47,7 +49,8 @@ interface CategoryNode {
MatDialogModule, MatDialogModule,
MatSnackBarModule, MatSnackBarModule,
MatTooltipModule, MatTooltipModule,
LoadingSkeletonComponent LoadingSkeletonComponent,
TranslatePipe
], ],
templateUrl: './project-view.component.html', templateUrl: './project-view.component.html',
styleUrls: ['./project-view.component.scss'] styleUrls: ['./project-view.component.scss']
@@ -66,7 +69,8 @@ export class ProjectViewComponent implements OnInit {
private apiService: ApiService, private apiService: ApiService,
private dialog: MatDialog, private dialog: MatDialog,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
private validationService: ValidationService private validationService: ValidationService,
public lang: LanguageService
) {} ) {}
ngOnInit() { ngOnInit() {

View File

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

View File

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

View File

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

View File

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