added translation
This commit is contained in:
79
API.md
79
API.md
@@ -3,6 +3,8 @@
|
||||
Endpoint reference for the Marketplace Backoffice.
|
||||
Base URL: `https://your-api-domain.com/api`
|
||||
|
||||
> 🇷🇺 Документация на русском языке: [API.ru.md](./API.ru.md)
|
||||
|
||||
---
|
||||
|
||||
## Projects
|
||||
@@ -237,6 +239,16 @@ Response 200:
|
||||
{ "key": "Storage", "value": "256GB" }
|
||||
],
|
||||
"subcategoryId": "sub1",
|
||||
"translations": {
|
||||
"ru": {
|
||||
"name": "iPhone 15 Про",
|
||||
"simpleDescription": "Последний iPhone...",
|
||||
"description": [
|
||||
{ "key": "Цвет", "value": "Чёрный" },
|
||||
{ "key": "Память", "value": "256 ГБ" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"comments": [
|
||||
{
|
||||
"id": "c1",
|
||||
@@ -280,8 +292,17 @@ Body:
|
||||
"simpleDescription": "Short description",
|
||||
"description": [
|
||||
{ "key": "Size", "value": "Large" }
|
||||
],
|
||||
"translations": { // optional - localized content for marketplace
|
||||
"ru": {
|
||||
"name": "Новый товар",
|
||||
"simpleDescription": "Краткое описание",
|
||||
"description": [
|
||||
{ "key": "Размер", "value": "Большой" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
- `imgs`: always send the **complete** array on update, not individual images.
|
||||
- `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.
|
||||
|
||||
---
|
||||
@@ -423,3 +445,60 @@ The `id` field on subcategories is editable via `PATCH` to allow renaming slugs.
|
||||
| Category | all subcategories (recursive) and their items |
|
||||
| Subcategory | all nested subcategories (recursive) and their items |
|
||||
| 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
503
API.ru.md
Normal 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.
|
||||
|
||||
### Каскадное удаление
|
||||
|
||||
| Удаляемый объект | Также удаляется |
|
||||
|---|---|
|
||||
| Категория | все подкатегории (рекурсивно) и их товары |
|
||||
| Подкатегория | все вложенные подкатегории (рекурсивно) и их товары |
|
||||
| Товар | ничего больше |
|
||||
234
src/app/i18n/translations.ts
Normal file
234
src/app/i18n/translations.ts
Normal 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: 'Перевод сохранён',
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
21
src/app/pipes/translate.pipe.ts
Normal file
21
src/app/pipes/translate.pipe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
34
src/app/services/language.service.ts
Normal file
34
src/app/services/language.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user