diff --git a/API.md b/API.md index e105ca4..0feef0f 100644 --- a/API.md +++ b/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,7 +292,16 @@ 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. diff --git a/API.ru.md b/API.ru.md new file mode 100644 index 0000000..35b8c07 --- /dev/null +++ b/API.ru.md @@ -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. + +### КаскадноС ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ + +| УдаляСмый ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ | Π’Π°ΠΊΠΆΠ΅ удаляСтся | +|---|---| +| ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡ | всС ΠΏΠΎΠ΄ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ (рСкурсивно) ΠΈ ΠΈΡ… Ρ‚ΠΎΠ²Π°Ρ€Ρ‹ | +| ΠŸΠΎΠ΄ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΡ | всС Π²Π»ΠΎΠΆΠ΅Π½Π½Ρ‹Π΅ ΠΏΠΎΠ΄ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ (рСкурсивно) ΠΈ ΠΈΡ… Ρ‚ΠΎΠ²Π°Ρ€Ρ‹ | +| Π’ΠΎΠ²Π°Ρ€ | Π½ΠΈΡ‡Π΅Π³ΠΎ большС | diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts new file mode 100644 index 0000000..960ac91 --- /dev/null +++ b/src/app/i18n/translations.ts @@ -0,0 +1,234 @@ +// Add new languages by name here β€” no other files need to change. +export const TRANSLATIONS: Record> = { + 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: 'ΠŸΠ΅Ρ€Π΅Π²ΠΎΠ΄ сохранён', + }, +}; diff --git a/src/app/models/category.model.ts b/src/app/models/category.model.ts index aef5c5a..be7c020 100644 --- a/src/app/models/category.model.ts +++ b/src/app/models/category.model.ts @@ -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 }; } diff --git a/src/app/models/item.model.ts b/src/app/models/item.model.ts index 9b1a9a2..b15f3bb 100644 --- a/src/app/models/item.model.ts +++ b/src/app/models/item.model.ts @@ -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 { diff --git a/src/app/pages/category-editor/category-editor.component.html b/src/app/pages/category-editor/category-editor.component.html index 76102cc..aae8c2c 100644 --- a/src/app/pages/category-editor/category-editor.component.html +++ b/src/app/pages/category-editor/category-editor.component.html @@ -6,30 +6,18 @@ -

Edit Category

+

{{ 'EDIT_CATEGORY' | translate }}

@if (saving()) { - Saving... + {{ 'SAVING' | translate }} } -
- Name - - @if (!category()!.name || category()!.name.trim().length === 0) { - Category name is required - } - - - - ID + {{ 'NAME' | translate }} @@ -38,12 +26,12 @@ [(ngModel)]="category()!.visible" (change)="onFieldChange('visible', category()!.visible)" color="primary"> - Visible + {{ 'VISIBLE' | translate }}
- Priority + {{ 'PRIORITY' | translate }} - Lower numbers appear first + {{ 'PRIORITY_HINT' | translate }} @if (category()!.priority < 0) { - Priority cannot be negative + {{ 'PRIORITY_HINT' | translate }} }
-

Image

+

{{ 'IMAGE' | translate }}

@if (category()!.img) {
@@ -70,7 +58,7 @@
- Or enter image URL + {{ 'IMAGE_URL' | translate }}
-

Subcategories ({{ category()!.subcategories?.length || 0 }})

-
@@ -103,7 +91,7 @@ @for (sub of category()!.subcategories; track sub.id) { {{ sub.name }} - Priority: {{ sub.priority }} + {{ 'PRIORITY' | translate }}: {{ sub.priority }} @@ -111,9 +99,18 @@ } } @else { -

No subcategories yet

+

{{ 'NO_SUBCATEGORIES' | translate }}

}
+ +
+

{{ 'TRANSLATIONS' | translate }}

+

{{ 'TRANSLATIONS_HINT' | translate }}

+ + {{ 'NAME_TRANSLATED' | translate }} + + +
}
diff --git a/src/app/pages/category-editor/category-editor.component.ts b/src/app/pages/category-editor/category-editor.component.ts index 0f80cf3..e49b2b6 100644 --- a/src/app/pages/category-editor/category-editor.component.ts +++ b/src/app/pages/category-editor/category-editor.component.ts @@ -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(''); projectId = signal(''); + /** 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); diff --git a/src/app/pages/item-editor/item-editor.component.html b/src/app/pages/item-editor/item-editor.component.html index 09bddfb..4f73f68 100644 --- a/src/app/pages/item-editor/item-editor.component.html +++ b/src/app/pages/item-editor/item-editor.component.html @@ -7,15 +7,15 @@ -

Edit Item

+

{{ 'EDIT_ITEM' | translate }}

@if (saving()) { - Saving... + {{ 'SAVING' | translate }} } +
+ } + + + + +
+ +
+ + + } diff --git a/src/app/pages/item-editor/item-editor.component.ts b/src/app/pages/item-editor/item-editor.component.ts index e0e43f8..0feb541 100644 --- a/src/app/pages/item-editor/item-editor.component.ts +++ b/src/app/pages/item-editor/item-editor.component.ts @@ -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(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; diff --git a/src/app/pages/item-preview/item-preview.component.html b/src/app/pages/item-preview/item-preview.component.html index 1e4630d..c6f9c2c 100644 --- a/src/app/pages/item-preview/item-preview.component.html +++ b/src/app/pages/item-preview/item-preview.component.html @@ -6,12 +6,12 @@ visibility - Preview + {{ 'PREVIEW' | translate }} @@ -33,13 +33,13 @@ } @else {
image - No image + {{ 'NO_IMAGES' | translate }}
} @if (item.quantity === 0) { -
Out of Stock
+
{{ 'OUT_OF_STOCK_BANNER' | translate }}
} @@ -76,12 +76,12 @@ @if (item.quantity > 0) { check_circle - In stock ({{ item.quantity }}) + {{ 'IN_STOCK' | translate }} ({{ item.quantity }}) } @else { cancel - Out of stock + {{ 'OUT_OF_STOCK' | translate }} } @@ -108,7 +108,7 @@ @if (item.badges?.length) {
- +
@for (badge of item.badges || []; track badge) { {{ badge }} @@ -120,7 +120,7 @@ @if (item?.tags?.length) {
- +
@for (tag of item.tags; track tag) { {{ tag }} @@ -131,12 +131,12 @@
- Priority: {{ item.priority }} + {{ 'PRIORITY' | translate }}: {{ item.priority }} {{ item.visible ? 'visibility' : 'visibility_off' }} - {{ item.visible ? 'Visible' : 'Hidden' }} + {{ item.visible ? ('VISIBLE' | translate) : ('HIDDEN' | translate) }}
@@ -145,8 +145,8 @@ } @else {
error_outline -

Item not found

- +

{{ 'ITEM_NOT_FOUND' | translate }}

+
}
diff --git a/src/app/pages/item-preview/item-preview.component.ts b/src/app/pages/item-preview/item-preview.component.ts index ecafd75..35f1dee 100644 --- a/src/app/pages/item-preview/item-preview.component.ts +++ b/src/app/pages/item-preview/item-preview.component.ts @@ -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() { diff --git a/src/app/pages/items-list/items-list.component.html b/src/app/pages/items-list/items-list.component.html index 0166a18..2c06c41 100644 --- a/src/app/pages/items-list/items-list.component.html +++ b/src/app/pages/items-list/items-list.component.html @@ -12,36 +12,36 @@
- Search items + {{ 'SEARCH_ITEMS' | translate }} + [placeholder]="'SEARCH_PLACEHOLDER' | translate"> - Visibility + {{ 'VISIBILITY' | translate }} - All - Visible - Hidden + {{ 'ALL' | translate }} + {{ 'VISIBLE' | translate }} + {{ 'HIDDEN' | translate }} @if (selectedItems().size > 0) {
- {{ selectedItems().size }} selected + {{ selectedItems().size }} {{ 'SELECTED' | translate }}
} @@ -98,11 +98,11 @@
{{ item.price }} {{ item.currency }} - Qty: {{ item.quantity }} + {{ 'QTY' | translate }}: {{ item.quantity }}
- Priority: {{ item.priority }} + {{ 'PRIORITY' | translate }}: {{ item.priority }} {{ item.visible ? 'visibility' : 'visibility_off' }} @@ -126,20 +126,20 @@ @if (loading()) {
- Loading more items... + {{ 'LOADING_MORE' | translate }}
} @if (!hasMore() && items().length > 0) {
- No more items to load + {{ 'NO_MORE_ITEMS' | translate }}
} @if (!loading() && items().length === 0) {
inventory_2 -

No items found

+

{{ 'NO_ITEMS_FOUND' | translate }}

} diff --git a/src/app/pages/items-list/items-list.component.ts b/src/app/pages/items-list/items-list.component.ts index b75cf84..9ff9602 100644 --- a/src/app/pages/items-list/items-list.component.ts +++ b/src/app/pages/items-list/items-list.component.ts @@ -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() { diff --git a/src/app/pages/project-view/project-view.component.html b/src/app/pages/project-view/project-view.component.html index c5b99b9..9afb00f 100644 --- a/src/app/pages/project-view/project-view.component.html +++ b/src/app/pages/project-view/project-view.component.html @@ -4,13 +4,18 @@ arrow_back {{ project()?.displayName || projectId() }} + +
+ + +
@@ -39,7 +44,7 @@ @@ -48,14 +53,14 @@ [checked]="node.visible" (change)="toggleVisibility(node, $event)" color="primary" - matTooltip="Toggle Visibility"> + [matTooltip]="'TOGGLE_VISIBILITY' | translate"> - -
@@ -77,8 +82,8 @@ @if (!hasActiveRoute()) {
dashboard -

Welcome to {{ project()?.displayName || 'Project' }} Backoffice

-

Select a category or subcategory from the sidebar to start editing.

+

{{ 'WELCOME_TO' | translate }} {{ project()?.displayName || 'Project' }} {{ 'BACKOFFICE' | translate }}

+

{{ 'SELECT_FROM_SIDEBAR' | translate }}

}
@@ -109,7 +114,7 @@ @@ -119,7 +124,7 @@ } @@ -128,14 +133,14 @@ [checked]="subNode.visible" (change)="toggleVisibility(subNode, $event)" color="primary" - matTooltip="Toggle Visibility"> + [matTooltip]="'TOGGLE_VISIBILITY' | translate"> - -
diff --git a/src/app/pages/project-view/project-view.component.scss b/src/app/pages/project-view/project-view.component.scss index f7980ce..5a49f1d 100644 --- a/src/app/pages/project-view/project-view.component.scss +++ b/src/app/pages/project-view/project-view.component.scss @@ -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); diff --git a/src/app/pages/project-view/project-view.component.ts b/src/app/pages/project-view/project-view.component.ts index 9a4667b..d878c49 100644 --- a/src/app/pages/project-view/project-view.component.ts +++ b/src/app/pages/project-view/project-view.component.ts @@ -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() { diff --git a/src/app/pages/projects-dashboard/projects-dashboard.component.html b/src/app/pages/projects-dashboard/projects-dashboard.component.html index 00581af..f14799c 100644 --- a/src/app/pages/projects-dashboard/projects-dashboard.component.html +++ b/src/app/pages/projects-dashboard/projects-dashboard.component.html @@ -1,5 +1,5 @@
-

Marketplace Backoffice

+

{{ 'MARKETPLACE_BACKOFFICE' | translate }}

@if (loading()) {
@@ -25,7 +25,7 @@
- {{ project.active ? 'Active' : 'Inactive' }} + {{ project.active ? ('ACTIVE' | translate) : ('INACTIVE' | translate) }}
diff --git a/src/app/pages/projects-dashboard/projects-dashboard.component.ts b/src/app/pages/projects-dashboard/projects-dashboard.component.ts index e9ecaa6..18d3908 100644 --- a/src/app/pages/projects-dashboard/projects-dashboard.component.ts +++ b/src/app/pages/projects-dashboard/projects-dashboard.component.ts @@ -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() { diff --git a/src/app/pages/subcategory-editor/subcategory-editor.component.html b/src/app/pages/subcategory-editor/subcategory-editor.component.html index 467efb6..1f7e543 100644 --- a/src/app/pages/subcategory-editor/subcategory-editor.component.html +++ b/src/app/pages/subcategory-editor/subcategory-editor.component.html @@ -7,13 +7,13 @@ -

Edit Subcategory

+

{{ 'EDIT_SUBCATEGORY' | translate }}

@if (saving()) { - Saving... + {{ 'SAVING' | translate }} } -
@@ -21,14 +21,14 @@
- Name - {{ 'NAME' | translate }} + @if (!subcategory()!.name || subcategory()!.name.trim().length === 0) { - Subcategory name is required + {{ 'NAME' | translate }} } @@ -39,7 +39,7 @@ [(ngModel)]="subcategory()!.id" (blur)="onFieldChange('id', subcategory()!.id)" required> - Used for routing - update carefully + {{ 'ID' | translate }} @if (!subcategory()!.id || subcategory()!.id.trim().length === 0) { ID is required } @@ -50,12 +50,12 @@ [(ngModel)]="subcategory()!.visible" (change)="onFieldChange('visible', subcategory()!.visible)" color="primary"> - Visible + {{ 'VISIBLE' | translate }}
- Priority + {{ 'PRIORITY' | translate }} - Lower numbers appear first + {{ 'PRIORITY_HINT' | translate }} @if (subcategory()!.priority < 0) { - Priority cannot be negative + {{ 'PRIORITY_HINT' | translate }} }
-

Image

+

{{ 'IMAGE' | translate }}

@if (subcategory()!.img) {
@@ -82,7 +82,7 @@
- Or enter image URL + {{ 'IMAGE_URL' | translate }} account_tree - This subcategory has child subcategories β€” items can only be added to leaf nodes. + {{ 'SUBCATEGORIES' | translate }}

} @else { }
+ +
+

{{ 'TRANSLATIONS' | translate }}

+

{{ 'TRANSLATIONS_HINT' | translate }}

+ + {{ 'NAME_TRANSLATED' | translate }} + + +
}
diff --git a/src/app/pages/subcategory-editor/subcategory-editor.component.ts b/src/app/pages/subcategory-editor/subcategory-editor.component.ts index 9b92066..315f40f 100644 --- a/src/app/pages/subcategory-editor/subcategory-editor.component.ts +++ b/src/app/pages/subcategory-editor/subcategory-editor.component.ts @@ -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(''); projectId = signal(''); + /** 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); diff --git a/src/app/pipes/translate.pipe.ts b/src/app/pipes/translate.pipe.ts new file mode 100644 index 0000000..bf9fdb1 --- /dev/null +++ b/src/app/pipes/translate.pipe.ts @@ -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); + } +} diff --git a/src/app/services/language.service.ts b/src/app/services/language.service.ts new file mode 100644 index 0000000..850bde3 --- /dev/null +++ b/src/app/services/language.service.ts @@ -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('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'; + } +}