Compare commits
5 Commits
auth-syste
...
350581cbe9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
350581cbe9 | ||
|
|
377da22761 | ||
|
|
421346d957 | ||
|
|
d6097e2b5d | ||
|
|
369af40f20 |
@@ -1,24 +1,471 @@
|
||||
# API Changes Required for Backend
|
||||
# Complete Backend API Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Frontend has been updated with two new features:
|
||||
1. **Region/Location system** — catalog filtering by region
|
||||
2. **Auth/Login system** — Telegram-based authentication required before payment
|
||||
|
||||
Base URLs:
|
||||
- Dexar: `https://api.dexarmarket.ru:445`
|
||||
- Novo: `https://api.novo.market:444`
|
||||
> **Last updated:** February 2026
|
||||
> **Frontend:** Angular 21 · Dual-brand (Dexar + Novo)
|
||||
> **Covers:** Catalog, Cart, Payments, Reviews, Regions, Auth, i18n, BackOffice
|
||||
|
||||
---
|
||||
|
||||
## 1. Region / Location Endpoints
|
||||
## Base URLs
|
||||
|
||||
### 1.1 `GET /regions` — List available regions
|
||||
| Brand | Dev | Production |
|
||||
|--------|----------------------------------|----------------------------------|
|
||||
| Dexar | `https://api.dexarmarket.ru:445` | `https://api.dexarmarket.ru:445` |
|
||||
| Novo | `https://api.novo.market:444` | `https://api.novo.market:444` |
|
||||
|
||||
Returns the list of regions where the marketplace operates.
|
||||
---
|
||||
|
||||
**Response** `200 OK`
|
||||
## Global HTTP Headers
|
||||
|
||||
The frontend **automatically attaches** two custom headers to **every API request** via an interceptor. The backend should read these headers and use them to filter/translate responses accordingly.
|
||||
|
||||
| Header | Example Value | Description |
|
||||
|---------------|---------------|------------------------------------------------------------|
|
||||
| `X-Region` | `moscow` | Region ID selected by the user. **Absent** = global (all). |
|
||||
| `X-Language` | `ru` | Active UI language: `ru`, `en`, or `hy`. |
|
||||
|
||||
### Backend behavior
|
||||
|
||||
- **`X-Region`**: If present, filter items/categories to only those available in that region. If absent, return everything (global catalog).
|
||||
- **`X-Language`**: If present, return translated `name`, `description`, etc. for categories/items when translations exist. If absent or `ru`, use russians defaults.
|
||||
|
||||
### CORS requirements for these headers
|
||||
|
||||
```
|
||||
Access-Control-Allow-Headers: Content-Type, X-Region, X-Language
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Health Check
|
||||
|
||||
### `GET /ping`
|
||||
|
||||
Simple health check.
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "pong" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Catalog — Categories
|
||||
|
||||
### `GET /category`
|
||||
|
||||
Returns all top-level categories. Respects `X-Region` and `X-Language` headers.
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"categoryID": 1,
|
||||
"name": "Электроника",
|
||||
"parentID": 0,
|
||||
"icon": "https://...",
|
||||
"wideBanner": "https://...",
|
||||
"itemCount": 42,
|
||||
"priority": 10,
|
||||
|
||||
"id": "cat_abc123",
|
||||
"visible": true,
|
||||
"img": "https://...",
|
||||
"projectId": "proj_xyz",
|
||||
"subcategories": [
|
||||
{
|
||||
"id": "sub_001",
|
||||
"name": "Смартфоны",
|
||||
"visible": true,
|
||||
"priority": 5,
|
||||
"img": "https://...",
|
||||
"categoryId": "cat_abc123",
|
||||
"parentId": "cat_abc123",
|
||||
"itemCount": 20,
|
||||
"hasItems": true,
|
||||
"subcategories": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Category object:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------------|----------|----------------------------------------------------|
|
||||
| `categoryID` | number | yes | Legacy numeric ID |
|
||||
| `name` | string | yes | Category display name (translated if `X-Language`) |
|
||||
| `parentID` | number | yes | Parent category ID (`0` = top-level) |
|
||||
| `icon` | string | no | Category icon URL |
|
||||
| `wideBanner` | string | no | Wide banner image URL |
|
||||
| `itemCount` | number | no | Number of items in category |
|
||||
| `priority` | number | no | Sort priority (higher = first) |
|
||||
| `id` | string | no | BackOffice string ID |
|
||||
| `visible` | boolean | no | Whether category is shown (`true` default) |
|
||||
| `img` | string | no | BackOffice image URL (maps to `icon`) |
|
||||
| `projectId` | string | no | BackOffice project reference |
|
||||
| `subcategories` | Subcategory[] | no | Nested subcategories |
|
||||
|
||||
**Subcategory object:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------------|----------|------------------------------------|
|
||||
| `id` | string | yes | Subcategory ID |
|
||||
| `name` | string | yes | Display name |
|
||||
| `visible` | boolean | no | Whether visible |
|
||||
| `priority` | number | no | Sort priority |
|
||||
| `img` | string | no | Image URL |
|
||||
| `categoryId` | string | yes | Parent category ID |
|
||||
| `parentId` | string | yes | Direct parent ID |
|
||||
| `itemCount` | number | no | Number of items |
|
||||
| `hasItems` | boolean | no | Whether has any items |
|
||||
| `subcategories` | Subcategory[] | no | Nested children |
|
||||
|
||||
---
|
||||
|
||||
### `GET /category/:categoryID`
|
||||
|
||||
Returns items in a specific category. Respects `X-Region` and `X-Language` headers.
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|----------|--------|---------|--------------------|
|
||||
| `count` | number | `50` | Items per page |
|
||||
| `skip` | number | `0` | Offset for paging |
|
||||
|
||||
**Response `200`:** Array of [Item](#item-object) objects.
|
||||
|
||||
---
|
||||
|
||||
## 3. Items
|
||||
|
||||
### `GET /item/:itemID`
|
||||
|
||||
Returns a single item. Respects `X-Region` and `X-Language` headers.
|
||||
|
||||
**Response `200`:** A single [Item](#item-object) object.
|
||||
|
||||
---
|
||||
|
||||
### `GET /searchitems`
|
||||
|
||||
Full-text search across items. Respects `X-Region` and `X-Language` headers.
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|----------|--------|---------|----------------------|
|
||||
| `search` | string | — | Search query (required) |
|
||||
| `count` | number | `50` | Items per page |
|
||||
| `skip` | number | `0` | Offset for paging |
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"items": [ /* Item objects */ ],
|
||||
"total": 128,
|
||||
"count": 50,
|
||||
"skip": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /randomitems`
|
||||
|
||||
Returns random items for carousel/recommendations. Respects `X-Region` and `X-Language` headers.
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|------------|--------|---------|------------------------------------|
|
||||
| `count` | number | `5` | Number of items to return |
|
||||
| `category` | number | — | Optional: limit to this category |
|
||||
|
||||
**Response `200`:** Array of [Item](#item-object) objects.
|
||||
|
||||
---
|
||||
|
||||
### Item Object
|
||||
|
||||
The backend can return items in **either** legacy format or BackOffice format. The frontend normalizes both.
|
||||
|
||||
```json
|
||||
{
|
||||
"categoryID": 1,
|
||||
"itemID": 123,
|
||||
"name": "iPhone 15 Pro",
|
||||
"photos": [{ "url": "https://..." }],
|
||||
"description": "Описание товара",
|
||||
"currency": "RUB",
|
||||
"price": 89990,
|
||||
"discount": 10,
|
||||
"remainings": "high",
|
||||
"rating": 4.5,
|
||||
"callbacks": [
|
||||
{
|
||||
"rating": 5,
|
||||
"content": "Отличный товар!",
|
||||
"userID": "user_123",
|
||||
"timestamp": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"questions": [
|
||||
{
|
||||
"question": "Есть ли гарантия?",
|
||||
"answer": "Да, 12 месяцев",
|
||||
"upvotes": 5,
|
||||
"downvotes": 0
|
||||
}
|
||||
],
|
||||
|
||||
"id": "item_abc123",
|
||||
"visible": true,
|
||||
"priority": 10,
|
||||
"imgs": ["https://img1.jpg", "https://img2.jpg"],
|
||||
"tags": ["new", "popular"],
|
||||
"badges": ["bestseller", "sale"],
|
||||
"simpleDescription": "Краткое описание",
|
||||
"descriptionFields": [
|
||||
{ "key": "Процессор", "value": "A17 Pro" },
|
||||
{ "key": "Память", "value": "256 GB" }
|
||||
],
|
||||
"subcategoryId": "sub_001",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Short description",
|
||||
"description": [
|
||||
{ "key": "Processor", "value": "A17 Pro" }
|
||||
]
|
||||
},
|
||||
"hy": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Կարcheck check check"
|
||||
}
|
||||
},
|
||||
"comments": [
|
||||
{
|
||||
"id": "cmt_001",
|
||||
"text": "Отличный товар!",
|
||||
"author": "user_123",
|
||||
"stars": 5,
|
||||
"createdAt": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"quantity": 50
|
||||
}
|
||||
```
|
||||
|
||||
**Full Item fields:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------------|-------------------|----------|------------------------------------------------------------|
|
||||
| `categoryID` | number | yes | Category this item belongs to |
|
||||
| `itemID` | number | yes | Legacy numeric item ID |
|
||||
| `name` | string | yes | Item display name |
|
||||
| `photos` | Photo[] | no | Legacy photo array `[{ url }]` |
|
||||
| `description` | string | yes | Text description |
|
||||
| `currency` | string | yes | Currency code (default: `RUB`) |
|
||||
| `price` | number | yes | Price in the currency's smallest display unit |
|
||||
| `discount` | number | yes | Discount percentage (`0`–`100`) |
|
||||
| `remainings` | string | no | Stock level: `high`, `medium`, `low`, `out` |
|
||||
| `rating` | number | yes | Average rating (`0`–`5`) |
|
||||
| `callbacks` | Review[] | no | Legacy reviews (alias for reviews) |
|
||||
| `questions` | Question[] | no | Q&A entries |
|
||||
| `id` | string | no | BackOffice string ID |
|
||||
| `visible` | boolean | no | Whether item is visible (`true` default) |
|
||||
| `priority` | number | no | Sort priority (higher = first) |
|
||||
| `imgs` | string[] | no | BackOffice image URLs (maps to `photos`) |
|
||||
| `tags` | string[] | no | Item tags for filtering |
|
||||
| `badges` | string[] | no | Display badges (`bestseller`, `sale`, etc.) |
|
||||
| `simpleDescription` | string | no | Short plain-text description |
|
||||
| `descriptionFields` | DescriptionField[]| no | Structured `[{ key, value }]` descriptions |
|
||||
| `subcategoryId` | string | no | BackOffice subcategory reference |
|
||||
| `translations` | Record | no | Translations keyed by lang code (see below) |
|
||||
| `comments` | Comment[] | no | BackOffice comments format |
|
||||
| `quantity` | number | no | Numeric stock count (maps to `remainings` on frontend) |
|
||||
|
||||
**Nested types:**
|
||||
|
||||
| Type | Fields |
|
||||
|--------------------|-----------------------------------------------------------------|
|
||||
| `Photo` | `url: string`, `photo?: string`, `video?: string`, `type?: string` |
|
||||
| `DescriptionField` | `key: string`, `value: string` |
|
||||
| `Comment` | `id?: string`, `text: string`, `author?: string`, `stars?: number`, `createdAt?: string` |
|
||||
| `Review` | `rating?: number`, `content?: string`, `userID?: string`, `answer?: string`, `timestamp?: string` |
|
||||
| `Question` | `question: string`, `answer: string`, `upvotes: number`, `downvotes: number` |
|
||||
| `ItemTranslation` | `name?: string`, `simpleDescription?: string`, `description?: DescriptionField[]` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Cart
|
||||
|
||||
### `POST /cart` — Add item to cart
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "itemID": 123, "quantity": 1 }
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Added to cart" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /cart` — Update item quantity
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "itemID": 123, "quantity": 3 }
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Updated" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /cart` — Remove items from cart
|
||||
|
||||
**Request body:** Array of item IDs
|
||||
```json
|
||||
[123, 456]
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Removed" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /cart` — Get cart contents
|
||||
|
||||
**Response `200`:** Array of [Item](#item-object) objects (each with `quantity` field).
|
||||
|
||||
---
|
||||
|
||||
## 5. Payments (SBP / QR)
|
||||
|
||||
### `POST /cart` — Create payment (SBP QR)
|
||||
|
||||
> Note: Same endpoint as add-to-cart but with different body schema.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"amount": 89990,
|
||||
"currency": "RUB",
|
||||
"siteuserID": "tg_123456789",
|
||||
"siteorderID": "order_abc123",
|
||||
"redirectUrl": "",
|
||||
"telegramUsername": "john_doe",
|
||||
"items": [
|
||||
{ "itemID": 123, "price": 89990, "name": "iPhone 15 Pro" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"qrId": "qr_abc123",
|
||||
"qrStatus": "CREATED",
|
||||
"qrExpirationDate": "2026-02-28T13:00:00Z",
|
||||
"payload": "https://qr.nspk.ru/...",
|
||||
"qrUrl": "https://qr.nspk.ru/..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /qr/payment/:qrId` — Check payment status
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"additionalInfo": "",
|
||||
"paymentPurpose": "Order #order_abc123",
|
||||
"amount": 89990,
|
||||
"code": "SUCCESS",
|
||||
"createDate": "2026-02-28T12:00:00Z",
|
||||
"currency": "RUB",
|
||||
"order": "order_abc123",
|
||||
"paymentStatus": "COMPLETED",
|
||||
"qrId": "qr_abc123",
|
||||
"transactionDate": "2026-02-28T12:01:00Z",
|
||||
"transactionId": 999,
|
||||
"qrExpirationDate": "2026-02-28T13:00:00Z",
|
||||
"phoneNumber": "+7XXXXXXXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
| `paymentStatus` values | Meaning |
|
||||
|------------------------|---------------------------|
|
||||
| `CREATED` | QR generated, not paid |
|
||||
| `WAITING` | Payment in progress |
|
||||
| `COMPLETED` | Payment successful |
|
||||
| `EXPIRED` | QR code expired |
|
||||
| `CANCELLED` | Payment cancelled |
|
||||
|
||||
---
|
||||
|
||||
### `POST /purchase-email` — Submit email after payment
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"telegramUserId": "123456789",
|
||||
"items": [
|
||||
{ "itemID": 123, "name": "iPhone 15 Pro", "price": 89990, "currency": "RUB" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Email sent" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Reviews / Comments
|
||||
|
||||
### `POST /comment` — Submit a review
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"itemID": 123,
|
||||
"rating": 5,
|
||||
"comment": "Great product!",
|
||||
"username": "john_doe",
|
||||
"userId": 123456789,
|
||||
"timestamp": "2026-02-28T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Review submitted" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Regions
|
||||
|
||||
### `GET /regions` — List available regions
|
||||
|
||||
Returns regions where the marketplace operates.
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
@@ -46,52 +493,44 @@ Returns the list of regions where the marketplace operates.
|
||||
```
|
||||
|
||||
**Region object:**
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|--------|----------|------------------------------|
|
||||
| `id` | string | yes | Unique region identifier |
|
||||
| `city` | string | yes | City name (display) |
|
||||
| `country` | string | yes | Country name (display) |
|
||||
| `countryCode` | string | yes | ISO 3166-1 alpha-2 code |
|
||||
| `timezone` | string | no | IANA timezone string |
|
||||
|
||||
> If this endpoint is unavailable, the frontend falls back to 6 hardcoded regions (Moscow, SPB, Yerevan, Minsk, Almaty, Tbilisi).
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|--------|----------|--------------------------|
|
||||
| `id` | string | yes | Unique region identifier |
|
||||
| `city` | string | yes | City name (display) |
|
||||
| `country` | string | yes | Country name |
|
||||
| `countryCode` | string | yes | ISO 3166-1 alpha-2 |
|
||||
| `timezone` | string | no | IANA timezone |
|
||||
|
||||
> **Fallback:** If this endpoint is down, the frontend uses 6 hardcoded defaults: Moscow, SPB, Yerevan, Minsk, Almaty, Tbilisi.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Region Query Parameter on Existing Endpoints
|
||||
|
||||
The following **existing** endpoints now accept an optional `?region=` query parameter:
|
||||
|
||||
| Endpoint | Example |
|
||||
|---------------------------------|----------------------------------------------|
|
||||
| `GET /category` | `GET /category?region=moscow` |
|
||||
| `GET /category/:id` | `GET /category/5?count=50&skip=0®ion=spb` |
|
||||
| `GET /item/:id` | `GET /item/123?region=yerevan` |
|
||||
| `GET /searchitems` | `GET /searchitems?search=phone®ion=moscow` |
|
||||
| `GET /randomitems` | `GET /randomitems?count=5®ion=almaty` |
|
||||
|
||||
**Behavior:**
|
||||
- If `region` param is **present** → return only items/categories available in that region
|
||||
- If `region` param is **absent** → return all items globally (current behavior, no change)
|
||||
- The `region` value matches the `id` field from the `/regions` response
|
||||
|
||||
---
|
||||
|
||||
## 2. Auth / Login Endpoints
|
||||
## 8. Authentication (Telegram Login)
|
||||
|
||||
Authentication is **Telegram-based** with **cookie sessions** (HttpOnly, Secure, SameSite=None).
|
||||
|
||||
All auth endpoints must support CORS with `credentials: true`.
|
||||
All auth endpoints must include `withCredentials: true` CORS support.
|
||||
|
||||
### 2.1 `GET /auth/session` — Check current session
|
||||
### Auth flow
|
||||
|
||||
Called on every page load to check if the user has an active session.
|
||||
```
|
||||
1. User clicks "Checkout" → not authenticated → login dialog shown
|
||||
2. User clicks "Log in with Telegram" → opens https://t.me/{bot}?start=auth_{callback}
|
||||
3. User starts the bot in Telegram
|
||||
4. Bot sends user data → backend /auth/telegram/callback
|
||||
5. Backend creates session → sets Set-Cookie
|
||||
6. Frontend polls GET /auth/session every 3s
|
||||
7. Session detected → dialog closes → checkout proceeds
|
||||
```
|
||||
|
||||
**Request:**
|
||||
- Cookies: session cookie (set by backend)
|
||||
- CORS: `withCredentials: true`
|
||||
---
|
||||
|
||||
**Response `200 OK`** (authenticated):
|
||||
### `GET /auth/session` — Check current session
|
||||
|
||||
**Request:** Cookies only (session cookie set by backend).
|
||||
|
||||
**Response `200`** (authenticated):
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
@@ -103,7 +542,7 @@ Called on every page load to check if the user has an active session.
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200 OK`** (expired session):
|
||||
**Response `200`** (expired):
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
@@ -115,37 +554,29 @@ Called on every page load to check if the user has an active session.
|
||||
}
|
||||
```
|
||||
|
||||
**Response `401 Unauthorized`** (no session / invalid cookie):
|
||||
**Response `401`** (no session):
|
||||
```json
|
||||
{
|
||||
"error": "No active session"
|
||||
}
|
||||
{ "error": "No active session" }
|
||||
```
|
||||
|
||||
**AuthSession object:**
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------|----------|------------------------------------------|
|
||||
| `sessionId` | string | yes | Unique session ID |
|
||||
| `telegramUserId` | number | yes | Telegram user ID |
|
||||
| `username` | string? | no | Telegram @username (can be null) |
|
||||
| `displayName` | string | yes | User display name (first_name + last_name) |
|
||||
| `active` | boolean | yes | Whether session is currently valid |
|
||||
| `expiresAt` | string | yes | ISO 8601 expiration datetime |
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------|----------|--------------------------------------------|
|
||||
| `sessionId` | string | yes | Unique session ID |
|
||||
| `telegramUserId` | number | yes | Telegram user ID |
|
||||
| `username` | string? | no | Telegram @username (can be null) |
|
||||
| `displayName` | string | yes | User display name (first + last) |
|
||||
| `active` | boolean | yes | Whether session is valid |
|
||||
| `expiresAt` | string | yes | ISO 8601 expiration datetime |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `GET /auth/telegram/callback` — Telegram bot auth callback
|
||||
### `GET /auth/telegram/callback` — Telegram bot auth callback
|
||||
|
||||
This is the URL the Telegram bot redirects to after the user starts the bot.
|
||||
Called by the Telegram bot after user authenticates.
|
||||
|
||||
**Flow:**
|
||||
1. Frontend generates link: `https://t.me/{botUsername}?start=auth_{encodedCallbackUrl}`
|
||||
2. User clicks → opens Telegram → starts the bot
|
||||
3. Bot sends user data to this callback endpoint
|
||||
4. Backend creates session, sets `Set-Cookie` header
|
||||
5. Frontend polls `GET /auth/session` every 3 seconds to detect when session becomes active
|
||||
|
||||
**Request** (from Telegram bot / webhook):
|
||||
**Request body (from bot):**
|
||||
```json
|
||||
{
|
||||
"id": 123456789,
|
||||
@@ -158,7 +589,7 @@ This is the URL the Telegram bot redirects to after the user starts the bot.
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Should set a session cookie and return:
|
||||
**Response:** Must set a session cookie and return:
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
@@ -167,93 +598,89 @@ This is the URL the Telegram bot redirects to after the user starts the bot.
|
||||
```
|
||||
|
||||
**Cookie requirements:**
|
||||
| Attribute | Value | Notes |
|
||||
|------------|----------------|------------------------------------------|
|
||||
| `HttpOnly` | `true` | Not accessible via JS |
|
||||
| `Secure` | `true` | HTTPS only |
|
||||
|
||||
| Attribute | Value | Notes |
|
||||
|------------|----------------|--------------------------------------------|
|
||||
| `HttpOnly` | `true` | Not accessible via JS |
|
||||
| `Secure` | `true` | HTTPS only |
|
||||
| `SameSite` | `None` | Required for cross-origin (API ≠ frontend) |
|
||||
| `Path` | `/` | |
|
||||
| `Max-Age` | `86400` (24h) | Or as needed |
|
||||
| `Domain` | API domain | |
|
||||
|
||||
> **Important:** Since the API domain differs from the frontend domain, `SameSite=None` + `Secure=true` is required for the cookie to be sent cross-origin.
|
||||
| `Path` | `/` | |
|
||||
| `Max-Age` | `86400` (24h) | Or as needed |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `POST /auth/logout` — End session
|
||||
### `POST /auth/logout` — End session
|
||||
|
||||
**Request:**
|
||||
- Cookies: session cookie
|
||||
- CORS: `withCredentials: true`
|
||||
- Body: `{}` (empty)
|
||||
**Request:** Cookies only, empty body `{}`
|
||||
|
||||
**Response `200 OK`:**
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"message": "Logged out"
|
||||
}
|
||||
{ "message": "Logged out" }
|
||||
```
|
||||
|
||||
Should clear/invalidate the session cookie.
|
||||
Must clear/invalidate the session cookie.
|
||||
|
||||
---
|
||||
|
||||
## 3. CORS Configuration
|
||||
### Session refresh
|
||||
|
||||
For auth cookies to work cross-origin, the backend CORS config must include:
|
||||
The frontend re-checks the session **60 seconds before `expiresAt`**. If the backend supports sliding expiration, it can reset the cookie's `Max-Age` on each `GET /auth/session`.
|
||||
|
||||
---
|
||||
|
||||
## 9. i18n / Translations
|
||||
|
||||
The frontend supports 3 languages: **Russian (ru)**, **English (en)**, **Armenian (hy)**.
|
||||
|
||||
The active language is sent via the `X-Language` HTTP header on every request.
|
||||
|
||||
### What the backend should do with `X-Language`
|
||||
|
||||
1. **Categories & items**: If `translations` field exists for the requested language, return the translated `name`, `description`, etc. OR the backend can apply translations server-side and return already-translated fields.
|
||||
|
||||
2. **The `translations` field** on items (optional approach):
|
||||
```json
|
||||
{
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Short desc in English",
|
||||
"description": [{ "key": "Processor", "value": "A17 Pro" }]
|
||||
},
|
||||
"hy": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Կarcheck check"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Recommended approach**: Read `X-Language` header and return the `name`/`description` in that language directly. If no translation exists, return the Russian default.
|
||||
|
||||
---
|
||||
|
||||
## 10. CORS Configuration
|
||||
|
||||
For auth cookies and custom headers to work, the backend CORS config must include:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: https://dexarmarket.ru (NOT *)
|
||||
Access-Control-Allow-Origin: https://dexarmarket.ru (NOT wildcard *)
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Content-Type, Authorization
|
||||
Access-Control-Allow-Headers: Content-Type, X-Region, X-Language
|
||||
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
|
||||
```
|
||||
|
||||
> `Access-Control-Allow-Origin` **cannot** be `*` when `Allow-Credentials: true`. Must be the exact frontend origin.
|
||||
> **Important:** `Access-Control-Allow-Origin` cannot be `*` when `Allow-Credentials: true`. Must be the exact frontend origin.
|
||||
|
||||
For Novo, also allow `https://novo.market`.
|
||||
**Allowed origins:**
|
||||
- `https://dexarmarket.ru`
|
||||
- `https://novo.market`
|
||||
- `http://localhost:4200` (dev)
|
||||
- `http://localhost:4201` (dev, Novo)
|
||||
|
||||
---
|
||||
|
||||
## 4. Session Refresh Behavior
|
||||
|
||||
The frontend automatically re-checks the session **60 seconds before `expiresAt`**. If the backend supports session extension (sliding expiration), it can re-set the cookie with a fresh `Max-Age` on every `GET /auth/session` call.
|
||||
|
||||
---
|
||||
|
||||
## 5. Auth Gate — Checkout Flow
|
||||
|
||||
The checkout button (`POST /cart` payment) now requires authentication:
|
||||
- If the user is **not logged in** → frontend shows a Telegram login dialog instead of proceeding
|
||||
- If the user **is logged in** → checkout proceeds normally
|
||||
- The session cookie is sent automatically with the payment request
|
||||
|
||||
No backend changes needed for the payment endpoint itself — just ensure it reads the session cookie if needed for order association.
|
||||
|
||||
---
|
||||
|
||||
## Summary of New Endpoints
|
||||
|
||||
| Method | Path | Purpose | Auth Required |
|
||||
|--------|----------------------------|-----------------------------|---------------|
|
||||
| `GET` | `/regions` | List available regions | No |
|
||||
| `GET` | `/auth/session` | Check current session | Cookie |
|
||||
| `GET` | `/auth/telegram/callback` | Telegram bot auth callback | No (from bot) |
|
||||
| `POST` | `/auth/logout` | End session | Cookie |
|
||||
|
||||
## Summary of Modified Endpoints
|
||||
|
||||
| Method | Path | Change |
|
||||
|--------|-------------------|---------------------------------------|
|
||||
| `GET` | `/category` | Added optional `?region=` param |
|
||||
| `GET` | `/category/:id` | Added optional `?region=` param |
|
||||
| `GET` | `/item/:id` | Added optional `?region=` param |
|
||||
| `GET` | `/searchitems` | Added optional `?region=` param |
|
||||
| `GET` | `/randomitems` | Added optional `?region=` param |
|
||||
|
||||
---
|
||||
|
||||
## Telegram Bot Setup
|
||||
## 11. Telegram Bot Setup
|
||||
|
||||
Each brand needs its own bot:
|
||||
- **Dexar:** `@dexarmarket_bot`
|
||||
@@ -262,5 +689,38 @@ Each brand needs its own bot:
|
||||
The bot should:
|
||||
1. Listen for `/start auth_{callbackUrl}` command
|
||||
2. Extract the callback URL
|
||||
3. Send the user's Telegram data (id, first_name, username, etc.) to that callback URL
|
||||
3. Send the user's Telegram data (`id`, `first_name`, `username`, etc.) to that callback URL
|
||||
4. The callback URL is `{apiUrl}/auth/telegram/callback`
|
||||
|
||||
---
|
||||
|
||||
## Complete Endpoint Reference
|
||||
|
||||
### New endpoints
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|---------------------------|----------------------------|----------|
|
||||
| `GET` | `/regions` | List available regions | No |
|
||||
| `GET` | `/auth/session` | Check current session | Cookie |
|
||||
| `GET` | `/auth/telegram/callback` | Telegram bot auth callback | No (bot) |
|
||||
| `POST` | `/auth/logout` | End session | Cookie |
|
||||
|
||||
### Existing endpoints
|
||||
|
||||
| Method | Path | Description | Auth | Headers |
|
||||
|----------|-----------------------|-------------------------|------|--------------------|
|
||||
| `GET` | `/ping` | Health check | No | — |
|
||||
| `GET` | `/category` | List categories | No | X-Region, X-Language |
|
||||
| `GET` | `/category/:id` | Items in category | No | X-Region, X-Language |
|
||||
| `GET` | `/item/:id` | Single item | No | X-Region, X-Language |
|
||||
| `GET` | `/searchitems` | Search items | No | X-Region, X-Language |
|
||||
| `GET` | `/randomitems` | Random items | No | X-Region, X-Language |
|
||||
| `POST` | `/cart` | Add to cart / Payment | No* | — |
|
||||
| `PATCH` | `/cart` | Update cart quantity | No* | — |
|
||||
| `DELETE` | `/cart` | Remove from cart | No* | — |
|
||||
| `GET` | `/cart` | Get cart contents | No* | — |
|
||||
| `POST` | `/comment` | Submit review | No | — |
|
||||
| `GET` | `/qr/payment/:qrId` | Check payment status | No | — |
|
||||
| `POST` | `/purchase-email` | Submit email after pay | No | — |
|
||||
|
||||
> \* Cart/payment endpoints may use the session cookie if available for order association, but don't strictly require auth. The frontend enforces auth before checkout.
|
||||
|
||||
726
docs/API_DOCS_RU.md
Normal file
726
docs/API_DOCS_RU.md
Normal file
@@ -0,0 +1,726 @@
|
||||
# Полная документация Backend API
|
||||
|
||||
> **Последнее обновление:** Февраль 2026
|
||||
> **Фронтенд:** Angular 21 · Два бренда (Dexar + Novo)
|
||||
> **Охватывает:** Каталог, Корзина, Оплата, Отзывы, Регионы, Авторизация, i18n, BackOffice
|
||||
|
||||
---
|
||||
|
||||
## Базовые URL
|
||||
|
||||
| Бренд | Dev | Production |
|
||||
|--------|----------------------------------|----------------------------------|
|
||||
| Dexar | `https://api.dexarmarket.ru:445` | `https://api.dexarmarket.ru:445` |
|
||||
| Novo | `https://api.novo.market:444` | `https://api.novo.market:444` |
|
||||
|
||||
---
|
||||
|
||||
## Глобальные HTTP-заголовки
|
||||
|
||||
Фронтенд **автоматически добавляет** два кастомных заголовка к **каждому API-запросу** через interceptor. Бэкенд должен читать эти заголовки и использовать для фильтрации/перевода ответов.
|
||||
|
||||
| Заголовок | Пример значения | Описание |
|
||||
|---------------|-----------------|-------------------------------------------------------------------|
|
||||
| `X-Region` | `moscow` | ID региона, выбранного пользователем. **Отсутствует** = все регионы. |
|
||||
| `X-Language` | `ru` | Активный язык интерфейса: `ru`, `en` или `hy`. |
|
||||
|
||||
### Поведение бэкенда
|
||||
|
||||
- **`X-Region`**: Если присутствует — фильтровать товары/категории только по этому региону. Если отсутствует — возвращать всё (глобальный каталог).
|
||||
- **`X-Language`**: Если присутствует — возвращать переведённые `name`, `description` и т.д., если переводы существуют. Если отсутствует или `ru` — возвращать на русском (по умолчанию).
|
||||
|
||||
### Требования CORS для этих заголовков
|
||||
|
||||
```
|
||||
Access-Control-Allow-Headers: Content-Type, X-Region, X-Language
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Проверка состояния
|
||||
|
||||
### `GET /ping`
|
||||
|
||||
Простая проверка работоспособности.
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "pong" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Каталог — Категории
|
||||
|
||||
### `GET /category`
|
||||
|
||||
Возвращает все категории верхнего уровня. Учитывает заголовки `X-Region` и `X-Language`.
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"categoryID": 1,
|
||||
"name": "Электроника",
|
||||
"parentID": 0,
|
||||
"icon": "https://...",
|
||||
"wideBanner": "https://...",
|
||||
"itemCount": 42,
|
||||
"priority": 10,
|
||||
|
||||
"id": "cat_abc123",
|
||||
"visible": true,
|
||||
"img": "https://...",
|
||||
"projectId": "proj_xyz",
|
||||
"subcategories": [
|
||||
{
|
||||
"id": "sub_001",
|
||||
"name": "Смартфоны",
|
||||
"visible": true,
|
||||
"priority": 5,
|
||||
"img": "https://...",
|
||||
"categoryId": "cat_abc123",
|
||||
"parentId": "cat_abc123",
|
||||
"itemCount": 20,
|
||||
"hasItems": true,
|
||||
"subcategories": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Объект Category:**
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|------------------|---------------|---------|-----------------------------------------------------|
|
||||
| `categoryID` | number | да | Числовой ID (legacy) |
|
||||
| `name` | string | да | Название категории (переведённое если `X-Language`) |
|
||||
| `parentID` | number | да | ID родительской категории (`0` = верхний уровень) |
|
||||
| `icon` | string | нет | URL иконки категории |
|
||||
| `wideBanner` | string | нет | URL широкого баннера |
|
||||
| `itemCount` | number | нет | Количество товаров в категории |
|
||||
| `priority` | number | нет | Приоритет сортировки (больше = выше) |
|
||||
| `id` | string | нет | Строковый ID из BackOffice |
|
||||
| `visible` | boolean | нет | Видима ли категория (по умолч. `true`) |
|
||||
| `img` | string | нет | URL изображения из BackOffice (маппится на `icon`) |
|
||||
| `projectId` | string | нет | Ссылка на проект в BackOffice |
|
||||
| `subcategories` | Subcategory[] | нет | Вложенные подкатегории |
|
||||
|
||||
**Объект Subcategory:**
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|------------------|---------------|---------|----------------------------------|
|
||||
| `id` | string | да | ID подкатегории |
|
||||
| `name` | string | да | Отображаемое название |
|
||||
| `visible` | boolean | нет | Видима ли |
|
||||
| `priority` | number | нет | Приоритет сортировки |
|
||||
| `img` | string | нет | URL изображения |
|
||||
| `categoryId` | string | да | ID родительской категории |
|
||||
| `parentId` | string | да | ID прямого родителя |
|
||||
| `itemCount` | number | нет | Количество товаров |
|
||||
| `hasItems` | boolean | нет | Есть ли товары |
|
||||
| `subcategories` | Subcategory[] | нет | Вложенные дочерние подкатегории |
|
||||
|
||||
---
|
||||
|
||||
### `GET /category/:categoryID`
|
||||
|
||||
Возвращает товары определённой категории. Учитывает заголовки `X-Region` и `X-Language`.
|
||||
|
||||
**Query-параметры:**
|
||||
|
||||
| Параметр | Тип | По умолч. | Описание |
|
||||
|----------|--------|-----------|-----------------------|
|
||||
| `count` | number | `50` | Товаров на страницу |
|
||||
| `skip` | number | `0` | Смещение для пагинации |
|
||||
|
||||
**Ответ `200`:** Массив объектов [Item](#объект-item).
|
||||
|
||||
---
|
||||
|
||||
## 3. Товары
|
||||
|
||||
### `GET /item/:itemID`
|
||||
|
||||
Возвращает один товар. Учитывает заголовки `X-Region` и `X-Language`.
|
||||
|
||||
**Ответ `200`:** Один объект [Item](#объект-item).
|
||||
|
||||
---
|
||||
|
||||
### `GET /searchitems`
|
||||
|
||||
Полнотекстовый поиск по товарам. Учитывает заголовки `X-Region` и `X-Language`.
|
||||
|
||||
**Query-параметры:**
|
||||
|
||||
| Параметр | Тип | По умолч. | Описание |
|
||||
|----------|--------|-----------|-------------------------------|
|
||||
| `search` | string | — | Поисковый запрос (обязателен) |
|
||||
| `count` | number | `50` | Товаров на страницу |
|
||||
| `skip` | number | `0` | Смещение для пагинации |
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{
|
||||
"items": [ /* объекты Item */ ],
|
||||
"total": 128,
|
||||
"count": 50,
|
||||
"skip": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /randomitems`
|
||||
|
||||
Возвращает случайные товары для карусели/рекомендаций. Учитывает заголовки `X-Region` и `X-Language`.
|
||||
|
||||
**Query-параметры:**
|
||||
|
||||
| Параметр | Тип | По умолч. | Описание |
|
||||
|------------|--------|-----------|--------------------------------------|
|
||||
| `count` | number | `5` | Количество товаров |
|
||||
| `category` | number | — | Ограничить данной категорией (опц.) |
|
||||
|
||||
**Ответ `200`:** Массив объектов [Item](#объект-item).
|
||||
|
||||
---
|
||||
|
||||
### Объект Item
|
||||
|
||||
Бэкенд может возвращать товары в **любом** из двух форматов — legacy или BackOffice. Фронтенд нормализует оба варианта.
|
||||
|
||||
```json
|
||||
{
|
||||
"categoryID": 1,
|
||||
"itemID": 123,
|
||||
"name": "iPhone 15 Pro",
|
||||
"photos": [{ "url": "https://..." }],
|
||||
"description": "Описание товара",
|
||||
"currency": "RUB",
|
||||
"price": 89990,
|
||||
"discount": 10,
|
||||
"remainings": "high",
|
||||
"rating": 4.5,
|
||||
"callbacks": [
|
||||
{
|
||||
"rating": 5,
|
||||
"content": "Отличный товар!",
|
||||
"userID": "user_123",
|
||||
"timestamp": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"questions": [
|
||||
{
|
||||
"question": "Есть ли гарантия?",
|
||||
"answer": "Да, 12 месяцев",
|
||||
"upvotes": 5,
|
||||
"downvotes": 0
|
||||
}
|
||||
],
|
||||
|
||||
"id": "item_abc123",
|
||||
"visible": true,
|
||||
"priority": 10,
|
||||
"imgs": ["https://img1.jpg", "https://img2.jpg"],
|
||||
"tags": ["new", "popular"],
|
||||
"badges": ["bestseller", "sale"],
|
||||
"simpleDescription": "Краткое описание",
|
||||
"descriptionFields": [
|
||||
{ "key": "Процессор", "value": "A17 Pro" },
|
||||
{ "key": "Память", "value": "256 GB" }
|
||||
],
|
||||
"subcategoryId": "sub_001",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Short description",
|
||||
"description": [
|
||||
{ "key": "Processor", "value": "A17 Pro" }
|
||||
]
|
||||
},
|
||||
"hy": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Կարcheck check"
|
||||
}
|
||||
},
|
||||
"comments": [
|
||||
{
|
||||
"id": "cmt_001",
|
||||
"text": "Отличный товар!",
|
||||
"author": "user_123",
|
||||
"stars": 5,
|
||||
"createdAt": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"quantity": 50
|
||||
}
|
||||
```
|
||||
|
||||
**Все поля Item:**
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|---------------------|-------------------|---------|-----------------------------------------------------------|
|
||||
| `categoryID` | number | да | Категория, к которой принадлежит товар |
|
||||
| `itemID` | number | да | Числовой ID товара (legacy) |
|
||||
| `name` | string | да | Название товара |
|
||||
| `photos` | Photo[] | нет | Массив фотографий `[{ url }]` (legacy) |
|
||||
| `description` | string | да | Текстовое описание |
|
||||
| `currency` | string | да | Код валюты (по умолч. `RUB`) |
|
||||
| `price` | number | да | Цена |
|
||||
| `discount` | number | да | Процент скидки (`0`–`100`) |
|
||||
| `remainings` | string | нет | Уровень остатка: `high`, `medium`, `low`, `out` |
|
||||
| `rating` | number | да | Средний рейтинг (`0`–`5`) |
|
||||
| `callbacks` | Review[] | нет | Отзывы (legacy формат) |
|
||||
| `questions` | Question[] | нет | Вопросы и ответы |
|
||||
| `id` | string | нет | Строковый ID из BackOffice |
|
||||
| `visible` | boolean | нет | Виден ли товар (по умолч. `true`) |
|
||||
| `priority` | number | нет | Приоритет сортировки (больше = выше) |
|
||||
| `imgs` | string[] | нет | URL картинок из BackOffice (маппится на `photos`) |
|
||||
| `tags` | string[] | нет | Теги для фильтрации |
|
||||
| `badges` | string[] | нет | Бейджи (`bestseller`, `sale` и т.д.) |
|
||||
| `simpleDescription` | string | нет | Краткое текстовое описание |
|
||||
| `descriptionFields` | DescriptionField[]| нет | Структурированное описание `[{ key, value }]` |
|
||||
| `subcategoryId` | string | нет | Ссылка на подкатегорию из BackOffice |
|
||||
| `translations` | Record | нет | Переводы по ключу языка (см. ниже) |
|
||||
| `comments` | Comment[] | нет | Комментарии в формате BackOffice |
|
||||
| `quantity` | number | нет | Числовое кол-во на складе (маппится на `remainings`) |
|
||||
|
||||
**Вложенные типы:**
|
||||
|
||||
| Тип | Поля |
|
||||
|--------------------|-----------------------------------------------------------------|
|
||||
| `Photo` | `url: string`, `photo?: string`, `video?: string`, `type?: string` |
|
||||
| `DescriptionField` | `key: string`, `value: string` |
|
||||
| `Comment` | `id?: string`, `text: string`, `author?: string`, `stars?: number`, `createdAt?: string` |
|
||||
| `Review` | `rating?: number`, `content?: string`, `userID?: string`, `answer?: string`, `timestamp?: string` |
|
||||
| `Question` | `question: string`, `answer: string`, `upvotes: number`, `downvotes: number` |
|
||||
| `ItemTranslation` | `name?: string`, `simpleDescription?: string`, `description?: DescriptionField[]` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Корзина
|
||||
|
||||
### `POST /cart` — Добавить товар в корзину
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{ "itemID": 123, "quantity": 1 }
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Added to cart" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /cart` — Обновить количество товара
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{ "itemID": 123, "quantity": 3 }
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Updated" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /cart` — Удалить товары из корзины
|
||||
|
||||
**Тело запроса:** Массив ID товаров
|
||||
```json
|
||||
[123, 456]
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Removed" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /cart` — Получить содержимое корзины
|
||||
|
||||
**Ответ `200`:** Массив объектов [Item](#объект-item) (каждый с полем `quantity`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Оплата (СБП / QR)
|
||||
|
||||
### `POST /cart` — Создать платёж (СБП QR)
|
||||
|
||||
> Примечание: Тот же эндпоинт что и добавление в корзину, но с другой схемой тела запроса.
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"amount": 89990,
|
||||
"currency": "RUB",
|
||||
"siteuserID": "tg_123456789",
|
||||
"siteorderID": "order_abc123",
|
||||
"redirectUrl": "",
|
||||
"telegramUsername": "john_doe",
|
||||
"items": [
|
||||
{ "itemID": 123, "price": 89990, "name": "iPhone 15 Pro" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{
|
||||
"qrId": "qr_abc123",
|
||||
"qrStatus": "CREATED",
|
||||
"qrExpirationDate": "2026-02-28T13:00:00Z",
|
||||
"payload": "https://qr.nspk.ru/...",
|
||||
"qrUrl": "https://qr.nspk.ru/..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /qr/payment/:qrId` — Проверить статус оплаты
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{
|
||||
"additionalInfo": "",
|
||||
"paymentPurpose": "Order #order_abc123",
|
||||
"amount": 89990,
|
||||
"code": "SUCCESS",
|
||||
"createDate": "2026-02-28T12:00:00Z",
|
||||
"currency": "RUB",
|
||||
"order": "order_abc123",
|
||||
"paymentStatus": "COMPLETED",
|
||||
"qrId": "qr_abc123",
|
||||
"transactionDate": "2026-02-28T12:01:00Z",
|
||||
"transactionId": 999,
|
||||
"qrExpirationDate": "2026-02-28T13:00:00Z",
|
||||
"phoneNumber": "+7XXXXXXXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
| Значение `paymentStatus` | Значение |
|
||||
|--------------------------|------------------------------|
|
||||
| `CREATED` | QR создан, не оплачен |
|
||||
| `WAITING` | Оплата в процессе |
|
||||
| `COMPLETED` | Оплата успешна |
|
||||
| `EXPIRED` | QR-код истёк |
|
||||
| `CANCELLED` | Оплата отменена |
|
||||
|
||||
---
|
||||
|
||||
### `POST /purchase-email` — Отправить email после оплаты
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"telegramUserId": "123456789",
|
||||
"items": [
|
||||
{ "itemID": 123, "name": "iPhone 15 Pro", "price": 89990, "currency": "RUB" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Email sent" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Отзывы / Комментарии
|
||||
|
||||
### `POST /comment` — Оставить отзыв
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"itemID": 123,
|
||||
"rating": 5,
|
||||
"comment": "Отличный товар!",
|
||||
"username": "john_doe",
|
||||
"userId": 123456789,
|
||||
"timestamp": "2026-02-28T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Review submitted" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Регионы
|
||||
|
||||
### `GET /regions` — Список доступных регионов
|
||||
|
||||
Возвращает регионы, в которых работает маркетплейс.
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "moscow",
|
||||
"city": "Москва",
|
||||
"country": "Россия",
|
||||
"countryCode": "RU",
|
||||
"timezone": "Europe/Moscow"
|
||||
},
|
||||
{
|
||||
"id": "spb",
|
||||
"city": "Санкт-Петербург",
|
||||
"country": "Россия",
|
||||
"countryCode": "RU",
|
||||
"timezone": "Europe/Moscow"
|
||||
},
|
||||
{
|
||||
"id": "yerevan",
|
||||
"city": "Ереван",
|
||||
"country": "Армения",
|
||||
"countryCode": "AM",
|
||||
"timezone": "Asia/Yerevan"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Объект Region:**
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|---------------|--------|---------|----------------------------------|
|
||||
| `id` | string | да | Уникальный идентификатор региона |
|
||||
| `city` | string | да | Название города |
|
||||
| `country` | string | да | Название страны |
|
||||
| `countryCode` | string | да | Код страны ISO 3166-1 alpha-2 |
|
||||
| `timezone` | string | нет | Часовой пояс IANA |
|
||||
|
||||
> **Фоллбэк:** Если эндпоинт недоступен, фронтенд использует 6 захардкоженных значений: Москва, СПб, Ереван, Минск, Алматы, Тбилиси.
|
||||
|
||||
---
|
||||
|
||||
## 8. Авторизация (вход через Telegram)
|
||||
|
||||
Авторизация **через Telegram** с **cookie-сессиями** (HttpOnly, Secure, SameSite=None).
|
||||
|
||||
Все auth-эндпоинты должны поддерживать CORS с `credentials: true`.
|
||||
|
||||
### Процесс авторизации
|
||||
|
||||
```
|
||||
1. Пользователь нажимает «Оформить заказ» → не авторизован → показывается диалог входа
|
||||
2. Нажимает «Войти через Telegram» → открывается https://t.me/{bot}?start=auth_{callback}
|
||||
3. Пользователь запускает бота в Telegram
|
||||
4. Бот отправляет данные пользователя → бэкенд /auth/telegram/callback
|
||||
5. Бэкенд создаёт сессию → устанавливает Set-Cookie
|
||||
6. Фронтенд опрашивает GET /auth/session каждые 3 секунды
|
||||
7. Сессия обнаружена → диалог закрывается → оформление заказа продолжается
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /auth/session` — Проверить текущую сессию
|
||||
|
||||
**Запрос:** Только cookie (сессионная cookie, установленная бэкендом).
|
||||
|
||||
**Ответ `200`** (авторизован):
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
"telegramUserId": 123456789,
|
||||
"username": "john_doe",
|
||||
"displayName": "John Doe",
|
||||
"active": true,
|
||||
"expiresAt": "2026-03-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ `200`** (сессия истекла):
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
"telegramUserId": 123456789,
|
||||
"username": "john_doe",
|
||||
"displayName": "John Doe",
|
||||
"active": false,
|
||||
"expiresAt": "2026-02-27T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ `401`** (нет сессии):
|
||||
```json
|
||||
{ "error": "No active session" }
|
||||
```
|
||||
|
||||
**Объект AuthSession:**
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|------------------|---------|---------|-------------------------------------------|
|
||||
| `sessionId` | string | да | Уникальный ID сессии |
|
||||
| `telegramUserId` | number | да | ID пользователя в Telegram |
|
||||
| `username` | string? | нет | @username в Telegram (может быть null) |
|
||||
| `displayName` | string | да | Отображаемое имя (имя + фамилия) |
|
||||
| `active` | boolean | да | Действительна ли сессия |
|
||||
| `expiresAt` | string | да | Дата истечения в формате ISO 8601 |
|
||||
|
||||
---
|
||||
|
||||
### `GET /auth/telegram/callback` — Callback авторизации Telegram-бота
|
||||
|
||||
Вызывается Telegram-ботом после авторизации пользователя.
|
||||
|
||||
**Тело запроса (от бота):**
|
||||
```json
|
||||
{
|
||||
"id": 123456789,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"username": "john_doe",
|
||||
"photo_url": "https://t.me/i/userpic/...",
|
||||
"auth_date": 1709100000,
|
||||
"hash": "abc123def456..."
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ:** Должен установить cookie сессии и вернуть:
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
"message": "Authenticated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Требования к cookie:**
|
||||
|
||||
| Атрибут | Значение | Примечание |
|
||||
|------------|----------------|-----------------------------------------------------|
|
||||
| `HttpOnly` | `true` | Недоступна из JavaScript |
|
||||
| `Secure` | `true` | Только HTTPS |
|
||||
| `SameSite` | `None` | Обязательно для cross-origin (API ≠ фронтенд) |
|
||||
| `Path` | `/` | |
|
||||
| `Max-Age` | `86400` (24ч) | Или по необходимости |
|
||||
|
||||
---
|
||||
|
||||
### `POST /auth/logout` — Завершить сессию
|
||||
|
||||
**Запрос:** Только cookie, пустое тело `{}`
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Logged out" }
|
||||
```
|
||||
|
||||
Должен очистить/инвалидировать cookie сессии.
|
||||
|
||||
---
|
||||
|
||||
### Обновление сессии
|
||||
|
||||
Фронтенд повторно проверяет сессию за **60 секунд до `expiresAt`**. Если бэкенд поддерживает скользящий срок действия (sliding expiration), можно обновлять `Max-Age` cookie при каждом вызове `GET /auth/session`.
|
||||
|
||||
---
|
||||
|
||||
## 9. i18n / Переводы
|
||||
|
||||
Фронтенд поддерживает 3 языка: **Русский (ru)**, **Английский (en)**, **Армянский (hy)**.
|
||||
|
||||
Активный язык отправляется через HTTP-заголовок `X-Language` с каждым запросом.
|
||||
|
||||
### Что бэкенд должен делать с `X-Language`
|
||||
|
||||
1. **Категории и товары**: Если для запрошенного языка есть поле `translations`, вернуть переведённые `name`, `description` и т.д. ИЛИ бэкенд может применять переводы на стороне сервера и возвращать уже переведённые поля.
|
||||
|
||||
2. **Поле `translations`** на товарах (опциональный подход):
|
||||
```json
|
||||
{
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Short desc in English",
|
||||
"description": [{ "key": "Processor", "value": "A17 Pro" }]
|
||||
},
|
||||
"hy": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Կarcheck check"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Рекомендуемый подход**: Читать заголовок `X-Language` и возвращать `name`/`description` на этом языке напрямую. Если перевода нет — возвращать русский вариант по умолчанию.
|
||||
|
||||
---
|
||||
|
||||
## 10. Настройка CORS
|
||||
|
||||
Для работы auth-cookie и кастомных заголовков конфигурация CORS бэкенда должна включать:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: https://dexarmarket.ru (НЕ wildcard *)
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Content-Type, X-Region, X-Language
|
||||
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
|
||||
```
|
||||
|
||||
> **Важно:** `Access-Control-Allow-Origin` не может быть `*` при `Allow-Credentials: true`. Должен быть точный origin фронтенда.
|
||||
|
||||
**Разрешённые origins:**
|
||||
- `https://dexarmarket.ru`
|
||||
- `https://novo.market`
|
||||
- `http://localhost:4200` (dev)
|
||||
- `http://localhost:4201` (dev, Novo)
|
||||
|
||||
---
|
||||
|
||||
## 11. Настройка Telegram-бота
|
||||
|
||||
Каждому бренду нужен свой бот:
|
||||
- **Dexar:** `@dexarmarket_bot`
|
||||
- **Novo:** `@novomarket_bot`
|
||||
|
||||
Бот должен:
|
||||
1. Слушать команду `/start auth_{callbackUrl}`
|
||||
2. Извлечь callback URL
|
||||
3. Отправить данные пользователя (`id`, `first_name`, `username` и т.д.) на этот callback URL
|
||||
4. Callback URL: `{apiUrl}/auth/telegram/callback`
|
||||
|
||||
---
|
||||
|
||||
## Полный справочник эндпоинтов
|
||||
|
||||
### Новые эндпоинты
|
||||
|
||||
| Метод | Путь | Описание | Авторизация |
|
||||
|--------|---------------------------|---------------------------------|-------------|
|
||||
| `GET` | `/regions` | Список доступных регионов | Нет |
|
||||
| `GET` | `/auth/session` | Проверка текущей сессии | Cookie |
|
||||
| `GET` | `/auth/telegram/callback` | Callback авторизации через бота | Нет (бот) |
|
||||
| `POST` | `/auth/logout` | Завершение сессии | Cookie |
|
||||
|
||||
### Существующие эндпоинты
|
||||
|
||||
| Метод | Путь | Описание | Авт. | Заголовки |
|
||||
|----------|-----------------------|---------------------------|------|--------------------|
|
||||
| `GET` | `/ping` | Проверка состояния | Нет | — |
|
||||
| `GET` | `/category` | Список категорий | Нет | X-Region, X-Language |
|
||||
| `GET` | `/category/:id` | Товары категории | Нет | X-Region, X-Language |
|
||||
| `GET` | `/item/:id` | Один товар | Нет | X-Region, X-Language |
|
||||
| `GET` | `/searchitems` | Поиск товаров | Нет | X-Region, X-Language |
|
||||
| `GET` | `/randomitems` | Случайные товары | Нет | X-Region, X-Language |
|
||||
| `POST` | `/cart` | Добавить в корзину / Оплата | Нет* | — |
|
||||
| `PATCH` | `/cart` | Обновить кол-во | Нет* | — |
|
||||
| `DELETE` | `/cart` | Удалить из корзины | Нет* | — |
|
||||
| `GET` | `/cart` | Содержимое корзины | Нет* | — |
|
||||
| `POST` | `/comment` | Оставить отзыв | Нет | — |
|
||||
| `GET` | `/qr/payment/:qrId` | Статус оплаты | Нет | — |
|
||||
| `POST` | `/purchase-email` | Отправить email после оплаты | Нет | — |
|
||||
|
||||
> \* Эндпоинты корзины/оплаты могут использовать cookie сессии (если есть) для привязки к заказу, но не требуют авторизации строго. Фронтенд проверяет авторизацию перед оформлением заказа.
|
||||
72
package-lock.json
generated
72
package-lock.json
generated
@@ -8,11 +8,15 @@
|
||||
"name": "dexarmarket",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.1.5",
|
||||
"@angular/cdk": "^21.1.5",
|
||||
"@angular/common": "^21.0.6",
|
||||
"@angular/compiler": "^21.0.6",
|
||||
"@angular/core": "^21.0.6",
|
||||
"@angular/forms": "^21.0.6",
|
||||
"@angular/material": "^21.1.5",
|
||||
"@angular/platform-browser": "^21.0.6",
|
||||
"@angular/platform-browser-dynamic": "^21.1.5",
|
||||
"@angular/router": "^21.0.6",
|
||||
"@angular/service-worker": "^21.0.6",
|
||||
"primeicons": "^7.0.0",
|
||||
@@ -324,6 +328,21 @@
|
||||
"yarn": ">= 1.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/animations": {
|
||||
"version": "21.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.1.5.tgz",
|
||||
"integrity": "sha512-gsqHX8lCYV8cgVtHs0iLwrX8SVlmcjUF44l/xCc/jBC/TeKWRl2e6Jqrn1Wcd0NDlGiNsm+mYNyqMyy5/I7kjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "21.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/build": {
|
||||
"version": "21.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.0.tgz",
|
||||
@@ -472,6 +491,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@angular/cdk": {
|
||||
"version": "21.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.5.tgz",
|
||||
"integrity": "sha512-AlQPgqe3LLwXCyrDwYSX3m/WKnl2ppCMW7Gb+7bJpIcpMdWYEpSOSQF318jXGYIysKg43YbdJ1tWhJWY/cbn3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^8.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^21.0.0 || ^22.0.0",
|
||||
"@angular/core": "^21.0.0 || ^22.0.0",
|
||||
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli": {
|
||||
"version": "21.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.0.tgz",
|
||||
@@ -613,6 +648,23 @@
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/material": {
|
||||
"version": "21.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/material/-/material-21.1.5.tgz",
|
||||
"integrity": "sha512-D6JvFulPvIKhPJ52prMV7DxwYMzcUpHar11ZcMb7r9WQzUfCS3FDPXfMAce5n3h+3kFccfmmGpnyBwqTlLPSig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/cdk": "21.1.5",
|
||||
"@angular/common": "^21.0.0 || ^22.0.0",
|
||||
"@angular/core": "^21.0.0 || ^22.0.0",
|
||||
"@angular/forms": "^21.0.0 || ^22.0.0",
|
||||
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "21.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.6.tgz",
|
||||
@@ -635,6 +687,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser-dynamic": {
|
||||
"version": "21.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.1.5.tgz",
|
||||
"integrity": "sha512-Pd8nPbJSIONnze1WS9wLBAtaFw4TYIH+ZGjKHS9G1E9l09tDWtHWyB7dY82Sc//Nc8iR4V7dcsbUmFjOJHThww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "21.1.5",
|
||||
"@angular/compiler": "21.1.5",
|
||||
"@angular/core": "21.1.5",
|
||||
"@angular/platform-browser": "21.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "21.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.6.tgz",
|
||||
@@ -7687,7 +7757,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
@@ -7741,7 +7810,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
|
||||
@@ -16,11 +16,15 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.1.5",
|
||||
"@angular/cdk": "^21.1.5",
|
||||
"@angular/common": "^21.0.6",
|
||||
"@angular/compiler": "^21.0.6",
|
||||
"@angular/core": "^21.0.6",
|
||||
"@angular/forms": "^21.0.6",
|
||||
"@angular/material": "^21.1.5",
|
||||
"@angular/platform-browser": "^21.0.6",
|
||||
"@angular/platform-browser-dynamic": "^21.1.5",
|
||||
"@angular/router": "^21.0.6",
|
||||
"@angular/service-worker": "^21.0.6",
|
||||
"primeicons": "^7.0.0",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { cacheInterceptor } from './interceptors/cache.interceptor';
|
||||
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
@@ -15,7 +16,7 @@ export const appConfig: ApplicationConfig = {
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'top' })
|
||||
),
|
||||
provideHttpClient(
|
||||
withInterceptors([cacheInterceptor])
|
||||
withInterceptors([apiHeadersInterceptor, cacheInterceptor])
|
||||
),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
@if (product.discount > 0) {
|
||||
<span class="discount-badge">-{{ product.discount }}%</span>
|
||||
}
|
||||
@if (product.badges && product.badges.length > 0) {
|
||||
<div class="item-badges-overlay">
|
||||
@for (badge of product.badges; track badge) {
|
||||
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="item-details">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TagModule } from 'primeng/tag';
|
||||
import { ApiService, CartService } from '../../services';
|
||||
import { Item } from '../../models';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { getDiscountedPrice, getMainImage } from '../../utils/item.utils';
|
||||
import { getDiscountedPrice, getMainImage, getBadgeClass } from '../../utils/item.utils';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
|
||||
@@ -98,6 +98,7 @@ export class ItemsCarouselComponent implements OnInit {
|
||||
|
||||
readonly getItemImage = getMainImage;
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
|
||||
addToCart(event: Event, item: Item): void {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -148,6 +148,7 @@ export const en: Translations = {
|
||||
mediumStock: 'Running low',
|
||||
addToCart: 'Add to cart',
|
||||
description: 'Description',
|
||||
specifications: 'Specifications',
|
||||
reviews: 'Reviews',
|
||||
yourReview: 'Your review',
|
||||
leaveReview: 'Leave a review',
|
||||
|
||||
@@ -2,188 +2,189 @@ import { Translations } from './translations';
|
||||
|
||||
export const hy: Translations = {
|
||||
header: {
|
||||
home: 'Գլխավոր',
|
||||
search: 'Որոնում',
|
||||
about: 'Մեր մասին',
|
||||
contacts: 'Կապ',
|
||||
searchPlaceholder: 'Որոնել...',
|
||||
catalog: 'Կատալոգ',
|
||||
home: '╘│╒м╒н╒б╒╛╒╕╓А',
|
||||
search: '╒И╓А╒╕╒╢╒╕╓В╒┤',
|
||||
about: '╒Д╒е╓А ╒┤╒б╒╜╒л╒╢',
|
||||
contacts: '╘┐╒б╒║',
|
||||
searchPlaceholder: '╒И╓А╒╕╒╢╒е╒м...',
|
||||
catalog: '╘┐╒б╒┐╒б╒м╒╕╒г',
|
||||
},
|
||||
footer: {
|
||||
description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար',
|
||||
company: 'Ընկերություն',
|
||||
aboutUs: 'Մեր մասին',
|
||||
contacts: 'Կապ',
|
||||
requisites: 'Վավերապայմաններ',
|
||||
support: 'Աջակցություն',
|
||||
faq: 'ՀՏՀ',
|
||||
delivery: 'Առաքում',
|
||||
guarantee: 'Երաշխիք',
|
||||
legal: 'Իրավական տեղեկատվություն',
|
||||
offer: 'Օֆերտա',
|
||||
privacy: 'Գաղտնիություն',
|
||||
returns: 'Վերադարձ',
|
||||
info: 'Տեղեկատվություն',
|
||||
aboutCompany: 'Ընկերության մասին',
|
||||
documents: 'Փաստաթղթեր',
|
||||
paymentRules: 'Վճարման կանոններ',
|
||||
returnPolicy: 'Վերադարձի քաղաքականություն',
|
||||
publicOffer: 'Հանրային օֆերտա',
|
||||
help: 'Օգնություն',
|
||||
payment: 'Վճարում',
|
||||
allRightsReserved: 'Բոլոր իրավունքները պաշտպանված են։',
|
||||
description: '╘║╒б╒┤╒б╒╢╒б╒п╒б╒п╒л╓Б ╒┤╒б╓А╓Д╒е╒й╓Г╒м╒е╒╡╒╜ ╒░╒б╓А╒┤╒б╓А ╒г╒╢╒╕╓В╒┤╒╢╒е╓А╒л ╒░╒б╒┤╒б╓А',
|
||||
company: '╘╕╒╢╒п╒е╓А╒╕╓В╒й╒╡╒╕╓В╒╢',
|
||||
aboutUs: '╒Д╒е╓А ╒┤╒б╒╜╒л╒╢',
|
||||
contacts: '╘┐╒б╒║',
|
||||
requisites: '╒О╒б╒╛╒е╓А╒б╒║╒б╒╡╒┤╒б╒╢╒╢╒е╓А',
|
||||
support: '╘▒╒╗╒б╒п╓Б╒╕╓В╒й╒╡╒╕╓В╒╢',
|
||||
faq: '╒А╒П╒А',
|
||||
delivery: '╘▒╒╝╒б╓Д╒╕╓В╒┤',
|
||||
guarantee: '╘╡╓А╒б╒╖╒н╒л╓Д',
|
||||
legal: '╘╗╓А╒б╒╛╒б╒п╒б╒╢ ╒┐╒е╒▓╒е╒п╒б╒┐╒╛╒╕╓В╒й╒╡╒╕╓В╒╢',
|
||||
offer: '╒Х╓Ж╒е╓А╒┐╒б',
|
||||
privacy: '╘│╒б╒▓╒┐╒╢╒л╒╕╓В╒й╒╡╒╕╓В╒╢',
|
||||
returns: '╒О╒е╓А╒б╒д╒б╓А╒▒',
|
||||
info: '╒П╒е╒▓╒е╒п╒б╒┐╒╛╒╕╓В╒й╒╡╒╕╓В╒╢',
|
||||
aboutCompany: '╘╕╒╢╒п╒е╓А╒╕╓В╒й╒╡╒б╒╢ ╒┤╒б╒╜╒л╒╢',
|
||||
documents: '╒У╒б╒╜╒┐╒б╒й╒▓╒й╒е╓А',
|
||||
paymentRules: '╒О╒│╒б╓А╒┤╒б╒╢ ╒п╒б╒╢╒╕╒╢╒╢╒е╓А',
|
||||
returnPolicy: '╒О╒е╓А╒б╒д╒б╓А╒▒╒л ╓Д╒б╒▓╒б╓Д╒б╒п╒б╒╢╒╕╓В╒й╒╡╒╕╓В╒╢',
|
||||
publicOffer: '╒А╒б╒╢╓А╒б╒╡╒л╒╢ ╓Е╓Ж╒е╓А╒┐╒б',
|
||||
help: '╒Х╒г╒╢╒╕╓В╒й╒╡╒╕╓В╒╢',
|
||||
payment: '╒О╒│╒б╓А╒╕╓В╒┤',
|
||||
allRightsReserved: '╘▓╒╕╒м╒╕╓А ╒л╓А╒б╒╛╒╕╓В╒╢╓Д╒╢╒е╓А╒и ╒║╒б╒╖╒┐╒║╒б╒╢╒╛╒б╒о ╒е╒╢╓Й',
|
||||
},
|
||||
home: {
|
||||
welcomeTo: 'Բարի գալուստ {{brand}}',
|
||||
subtitle: 'Գտեք ամեն ինչ մեկ վայրում',
|
||||
startSearch: 'Սկսել որոնումը',
|
||||
loading: 'Կատեգորիաները բեռնվում են...',
|
||||
errorTitle: 'Ինչ-որ բան սխալ է գնացել',
|
||||
retry: 'Փորձել կրկին',
|
||||
categoriesTitle: 'Ապրանքների կատեգորիաներ',
|
||||
categoriesSubtitle: 'Ընտրեք հետաքրքրող կատեգորիան',
|
||||
categoriesEmpty: 'Կատեգորիաները շուտով կհայտնվեն',
|
||||
categoriesEmptyDesc: 'Մենք աշխատում ենք կատալոգի համալրման վրա',
|
||||
dexarHeroTitle: 'Այստեղ դու կգտնես ամեն ինչ',
|
||||
dexarHeroSubtitle: 'Հազարավոր ապրանքներ մեկ վայրում',
|
||||
dexarHeroTagline: 'պարզ և հարմար',
|
||||
goToCatalog: 'Անցնել կատալոգ',
|
||||
findProduct: 'Գտնել ապրանք',
|
||||
loadingDexar: 'Կատեգորիաները բեռնվում են...',
|
||||
catalogTitle: 'Ապրանքների կատալոգ',
|
||||
emptyCategoriesDexar: 'Կատեգորիաները դեռ չկան',
|
||||
categoriesSoonDexar: 'Շուտով այստեղ կհայտնվեն ապրանքների կատեգորիաներ',
|
||||
itemsCount: '{{count}} ապրանք',
|
||||
welcomeTo: '╘▓╒б╓А╒л ╒г╒б╒м╒╕╓В╒╜╒┐ {{brand}}',
|
||||
subtitle: '╘│╒┐╒е╓Д ╒б╒┤╒е╒╢ ╒л╒╢╒╣ ╒┤╒е╒п ╒╛╒б╒╡╓А╒╕╓В╒┤',
|
||||
startSearch: '╒Н╒п╒╜╒е╒м ╒╕╓А╒╕╒╢╒╕╓В╒┤╒и',
|
||||
loading: '╘┐╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒е╒╢...',
|
||||
errorTitle: '╘╗╒╢╒╣-╒╕╓А ╒в╒б╒╢ ╒╜╒н╒б╒м ╒з ╒г╒╢╒б╓Б╒е╒м',
|
||||
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
|
||||
categoriesTitle: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒л ╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А',
|
||||
categoriesSubtitle: '╘╕╒╢╒┐╓А╒е╓Д ╒░╒е╒┐╒б╓Д╓А╓Д╓А╒╕╒▓ ╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢',
|
||||
categoriesEmpty: '╘┐╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А╒и ╒╖╒╕╓В╒┐╒╕╒╛ ╒п╒░╒б╒╡╒┐╒╢╒╛╒е╒╢',
|
||||
categoriesEmptyDesc: '╒Д╒е╒╢╓Д ╒б╒╖╒н╒б╒┐╒╕╓В╒┤ ╒е╒╢╓Д ╒п╒б╒┐╒б╒м╒╕╒г╒л ╒░╒б╒┤╒б╒м╓А╒┤╒б╒╢ ╒╛╓А╒б',
|
||||
dexarHeroTitle: '╘▒╒╡╒╜╒┐╒е╒▓ ╒д╒╕╓В ╒п╒г╒┐╒╢╒е╒╜ ╒б╒┤╒е╒╢ ╒л╒╢╒╣',
|
||||
dexarHeroSubtitle: '╒А╒б╒ж╒б╓А╒б╒╛╒╕╓А ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А ╒┤╒е╒п ╒╛╒б╒╡╓А╒╕╓В╒┤',
|
||||
dexarHeroTagline: '╒║╒б╓А╒ж ╓З ╒░╒б╓А╒┤╒б╓А',
|
||||
goToCatalog: '╘▒╒╢╓Б╒╢╒е╒м ╒п╒б╒┐╒б╒м╒╕╒г',
|
||||
findProduct: '╘│╒┐╒╢╒е╒м ╒б╒║╓А╒б╒╢╓Д',
|
||||
loadingDexar: '╘┐╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒е╒╢...',
|
||||
catalogTitle: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒л ╒п╒б╒┐╒б╒м╒╕╒г',
|
||||
emptyCategoriesDexar: '╘┐╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А╒и ╒д╒е╒╝ ╒╣╒п╒б╒╢',
|
||||
categoriesSoonDexar: '╒З╒╕╓В╒┐╒╕╒╛ ╒б╒╡╒╜╒┐╒е╒▓ ╒п╒░╒б╒╡╒┐╒╢╒╛╒е╒╢ ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А╒л ╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А',
|
||||
itemsCount: '{{count}} ╒б╒║╓А╒б╒╢╓Д',
|
||||
},
|
||||
cart: {
|
||||
title: 'Զամբյուղ',
|
||||
clear: 'Մաքրել',
|
||||
empty: 'Զամբյուղը դատարկ է',
|
||||
emptyDesc: 'Ավելացրեք ապրանքներ գնումները սկսելու համար',
|
||||
goShopping: 'Անցնել գնումների',
|
||||
total: 'Ընդամենը',
|
||||
items: 'Ապրանքներ',
|
||||
deliveryLabel: 'Առաքում',
|
||||
toPay: 'Վճարման ենթակա',
|
||||
agreeWith: 'Ես համաձայն եմ',
|
||||
publicOffer: 'հանրային օֆերտային',
|
||||
returnPolicy: 'վերադարձի քաղաքականությանը',
|
||||
guaranteeTerms: 'երաշխիքային պայմաններին',
|
||||
privacyPolicy: 'գաղտնիության քաղաքականությանը',
|
||||
and: 'և',
|
||||
checkout: 'Ձևակերպել պատվեր',
|
||||
close: 'Փակել',
|
||||
creatingPayment: 'Վճարումը ստեղծվում է...',
|
||||
waitFewSeconds: 'Սպասեք մի քանի վայրկյան',
|
||||
scanQr: 'Սկանավորեք QR կոդը վճարման համար',
|
||||
amountToPay: 'Վճարման գումարը՝',
|
||||
waitingPayment: 'Սպասում ենք վճարմանը...',
|
||||
copied: '✓ Պատճենված է',
|
||||
copyLink: 'Պատճենել հղումը',
|
||||
openNewTab: 'Բացել նոր ներդիրում',
|
||||
paymentSuccess: 'Շնորհավորում ենք։ Վճարումը հաջողությամբ կատարվել է։',
|
||||
paymentSuccessDesc: 'Մուտքագրեք ձեր կոնտակտային տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում',
|
||||
sending: 'Ուղարկվում է...',
|
||||
send: 'Ուղարկել',
|
||||
paymentTimeout: 'Սպասման ժամանակը սպառվել է',
|
||||
paymentTimeoutDesc: 'Մենք չենք ստացել վճարման հաստատում 3 րոպեի ընթացքում։',
|
||||
autoClose: 'Պատուհանը կփակվի ավտոմատ...',
|
||||
confirmClear: 'Համոզվա՞ծ եք, որ ցանկանում եք մաքրել զամբյուղը։',
|
||||
acceptTerms: 'Խնդրում ենք ընդունել օֆերտայի, վերադարձի և երաշխիքի պայմանները պատվերը հաստատելու համար։',
|
||||
copyError: 'Պատճենման սխալ՝',
|
||||
emailSuccess: 'Email-ը հաջողությամբ ուղարկվել է։ Ստուգեք ձեր փոստը։',
|
||||
emailError: 'Email ուղարկելու ժամանակ տեղի ունեցավ սխալ։ Խնդրում ենք փորձել կրկին։',
|
||||
phoneRequired: 'Հեռախոսահամարը պարտադիր է',
|
||||
phoneMoreDigits: 'Մուտքագրեք ևս {{count}} թիվ',
|
||||
phoneTooMany: 'Չափազանց շատ թվեր',
|
||||
emailRequired: 'Email-ը պարտադիր է',
|
||||
emailTooShort: 'Email-ը չափազանց կարճ է (նվազագույնը 5 նիշ)',
|
||||
emailTooLong: 'Email-ը չափազանց երկար է (առավելագույնը 100 նիշ)',
|
||||
emailNeedsAt: 'Email-ը պետք է պարունակի @ նշանը',
|
||||
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեն (.com, .ru և այլն)',
|
||||
emailInvalid: 'Email-ի ձևաչափը սխալ է',
|
||||
title: '╘╢╒б╒┤╒в╒╡╒╕╓В╒▓',
|
||||
clear: '╒Д╒б╓Д╓А╒е╒м',
|
||||
empty: '╘╢╒б╒┤╒в╒╡╒╕╓В╒▓╒и ╒д╒б╒┐╒б╓А╒п ╒з',
|
||||
emptyDesc: '╘▒╒╛╒е╒м╒б╓Б╓А╒е╓Д ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А ╒г╒╢╒╕╓В╒┤╒╢╒е╓А╒и ╒╜╒п╒╜╒е╒м╒╕╓В ╒░╒б╒┤╒б╓А',
|
||||
goShopping: '╘▒╒╢╓Б╒╢╒е╒м ╒г╒╢╒╕╓В╒┤╒╢╒е╓А╒л',
|
||||
total: '╘╕╒╢╒д╒б╒┤╒е╒╢╒и',
|
||||
items: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А',
|
||||
deliveryLabel: '╘▒╒╝╒б╓Д╒╕╓В╒┤',
|
||||
toPay: '╒О╒│╒б╓А╒┤╒б╒╢ ╒е╒╢╒й╒б╒п╒б',
|
||||
agreeWith: '╘╡╒╜ ╒░╒б╒┤╒б╒▒╒б╒╡╒╢ ╒е╒┤',
|
||||
publicOffer: '╒░╒б╒╢╓А╒б╒╡╒л╒╢ ╓Е╓Ж╒е╓А╒┐╒б╒╡╒л╒╢',
|
||||
returnPolicy: '╒╛╒е╓А╒б╒д╒б╓А╒▒╒л ╓Д╒б╒▓╒б╓Д╒б╒п╒б╒╢╒╕╓В╒й╒╡╒б╒╢╒и',
|
||||
guaranteeTerms: '╒е╓А╒б╒╖╒н╒л╓Д╒б╒╡╒л╒╢ ╒║╒б╒╡╒┤╒б╒╢╒╢╒е╓А╒л╒╢',
|
||||
privacyPolicy: '╒г╒б╒▓╒┐╒╢╒л╒╕╓В╒й╒╡╒б╒╢ ╓Д╒б╒▓╒б╓Д╒б╒п╒б╒╢╒╕╓В╒й╒╡╒б╒╢╒и',
|
||||
and: '╓З',
|
||||
checkout: '╒Б╓З╒б╒п╒е╓А╒║╒е╒м ╒║╒б╒┐╒╛╒е╓А',
|
||||
close: '╒У╒б╒п╒е╒м',
|
||||
creatingPayment: '╒О╒│╒б╓А╒╕╓В╒┤╒и ╒╜╒┐╒е╒▓╒о╒╛╒╕╓В╒┤ ╒з...',
|
||||
waitFewSeconds: '╒Н╒║╒б╒╜╒е╓Д ╒┤╒л ╓Д╒б╒╢╒л ╒╛╒б╒╡╓А╒п╒╡╒б╒╢',
|
||||
scanQr: '╒Н╒п╒б╒╢╒б╒╛╒╕╓А╒е╓Д QR ╒п╒╕╒д╒и ╒╛╒│╒б╓А╒┤╒б╒╢ ╒░╒б╒┤╒б╓А',
|
||||
amountToPay: '╒О╒│╒б╓А╒┤╒б╒╢ ╒г╒╕╓В╒┤╒б╓А╒и╒Э',
|
||||
waitingPayment: '╒Н╒║╒б╒╜╒╕╓В╒┤ ╒е╒╢╓Д ╒╛╒│╒б╓А╒┤╒б╒╢╒и...',
|
||||
copied: 'тЬУ ╒К╒б╒┐╒│╒е╒╢╒╛╒б╒о ╒з',
|
||||
copyLink: '╒К╒б╒┐╒│╒е╒╢╒е╒м ╒░╒▓╒╕╓В╒┤╒и',
|
||||
openNewTab: '╘▓╒б╓Б╒е╒м ╒╢╒╕╓А ╒╢╒е╓А╒д╒л╓А╒╕╓В╒┤',
|
||||
paymentSuccess: '╒З╒╢╒╕╓А╒░╒б╒╛╒╕╓А╒╕╓В╒┤ ╒е╒╢╓Д╓Й ╒О╒│╒б╓А╒╕╓В╒┤╒и ╒░╒б╒╗╒╕╒▓╒╕╓В╒й╒╡╒б╒┤╒в ╒п╒б╒┐╒б╓А╒╛╒е╒м ╒з╓Й',
|
||||
paymentSuccessDesc: '╒Д╒╕╓В╒┐╓Д╒б╒г╓А╒е╓Д ╒▒╒е╓А ╒п╒╕╒╢╒┐╒б╒п╒┐╒б╒╡╒л╒╢ ╒┐╒╛╒╡╒б╒м╒╢╒е╓А╒и, ╓З ╒┤╒е╒╢╓Д ╒п╒╕╓В╒▓╒б╓А╒п╒е╒╢╓Д ╒г╒╢╒╕╓В╒┤╒и ╒┤╒л ╓Д╒б╒╢╒л ╓А╒╕╒║╒е╒л ╒и╒╢╒й╒б╓Б╓Д╒╕╓В╒┤',
|
||||
sending: '╒И╓В╒▓╒б╓А╒п╒╛╒╕╓В╒┤ ╒з...',
|
||||
send: '╒И╓В╒▓╒б╓А╒п╒е╒м',
|
||||
paymentTimeout: '╒Н╒║╒б╒╜╒┤╒б╒╢ ╒к╒б╒┤╒б╒╢╒б╒п╒и ╒╜╒║╒б╒╝╒╛╒е╒м ╒з',
|
||||
paymentTimeoutDesc: '╒Д╒е╒╢╓Д ╒╣╒е╒╢╓Д ╒╜╒┐╒б╓Б╒е╒м ╒╛╒│╒б╓А╒┤╒б╒╢ ╒░╒б╒╜╒┐╒б╒┐╒╕╓В╒┤ 3 ╓А╒╕╒║╒е╒л ╒и╒╢╒й╒б╓Б╓Д╒╕╓В╒┤╓Й',
|
||||
autoClose: '╒К╒б╒┐╒╕╓В╒░╒б╒╢╒и ╒п╓Г╒б╒п╒╛╒л ╒б╒╛╒┐╒╕╒┤╒б╒┐...',
|
||||
confirmClear: '╒А╒б╒┤╒╕╒ж╒╛╒б╒Ю╒о ╒е╓Д, ╒╕╓А ╓Б╒б╒╢╒п╒б╒╢╒╕╓В╒┤ ╒е╓Д ╒┤╒б╓Д╓А╒е╒м ╒ж╒б╒┤╒в╒╡╒╕╓В╒▓╒и╓Й',
|
||||
acceptTerms: '╘╜╒╢╒д╓А╒╕╓В╒┤ ╒е╒╢╓Д ╒и╒╢╒д╒╕╓В╒╢╒е╒м ╓Е╓Ж╒е╓А╒┐╒б╒╡╒л, ╒╛╒е╓А╒б╒д╒б╓А╒▒╒л ╓З ╒е╓А╒б╒╖╒н╒л╓Д╒л ╒║╒б╒╡╒┤╒б╒╢╒╢╒е╓А╒и ╒║╒б╒┐╒╛╒е╓А╒и ╒░╒б╒╜╒┐╒б╒┐╒е╒м╒╕╓В ╒░╒б╒┤╒б╓А╓Й',
|
||||
copyError: '╒К╒б╒┐╒│╒е╒╢╒┤╒б╒╢ ╒╜╒н╒б╒м╒Э',
|
||||
emailSuccess: 'Email-╒и ╒░╒б╒╗╒╕╒▓╒╕╓В╒й╒╡╒б╒┤╒в ╒╕╓В╒▓╒б╓А╒п╒╛╒е╒м ╒з╓Й ╒Н╒┐╒╕╓В╒г╒е╓Д ╒▒╒е╓А ╓Г╒╕╒╜╒┐╒и╓Й',
|
||||
emailError: 'Email ╒╕╓В╒▓╒б╓А╒п╒е╒м╒╕╓В ╒к╒б╒┤╒б╒╢╒б╒п ╒┐╒е╒▓╒л ╒╕╓В╒╢╒е╓Б╒б╒╛ ╒╜╒н╒б╒м╓Й ╘╜╒╢╒д╓А╒╕╓В╒┤ ╒е╒╢╓Д ╓Г╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢╓Й',
|
||||
phoneRequired: '╒А╒е╒╝╒б╒н╒╕╒╜╒б╒░╒б╒┤╒б╓А╒и ╒║╒б╓А╒┐╒б╒д╒л╓А ╒з',
|
||||
phoneMoreDigits: '╒Д╒╕╓В╒┐╓Д╒б╒г╓А╒е╓Д ╓З╒╜ {{count}} ╒й╒л╒╛',
|
||||
phoneTooMany: '╒Й╒б╓Г╒б╒ж╒б╒╢╓Б ╒╖╒б╒┐ ╒й╒╛╒е╓А',
|
||||
emailRequired: 'Email-╒и ╒║╒б╓А╒┐╒б╒д╒л╓А ╒з',
|
||||
emailTooShort: 'Email-╒и ╒╣╒б╓Г╒б╒ж╒б╒╢╓Б ╒п╒б╓А╒│ ╒з (╒╢╒╛╒б╒ж╒б╒г╒╕╓В╒╡╒╢╒и 5 ╒╢╒л╒╖)',
|
||||
emailTooLong: 'Email-╒и ╒╣╒б╓Г╒б╒ж╒б╒╢╓Б ╒е╓А╒п╒б╓А ╒з (╒б╒╝╒б╒╛╒е╒м╒б╒г╒╕╓В╒╡╒╢╒и 100 ╒╢╒л╒╖)',
|
||||
emailNeedsAt: 'Email-╒и ╒║╒е╒┐╓Д ╒з ╒║╒б╓А╒╕╓В╒╢╒б╒п╒л @ ╒╢╒╖╒б╒╢╒и',
|
||||
emailNeedsDomain: 'Email-╒и ╒║╒е╒┐╓Д ╒з ╒║╒б╓А╒╕╓В╒╢╒б╒п╒л ╒д╒╕╒┤╒е╒╢ (.com, .ru ╓З ╒б╒╡╒м╒╢)',
|
||||
emailInvalid: 'Email-╒л ╒▒╓З╒б╒╣╒б╓Г╒и ╒╜╒н╒б╒м ╒з',
|
||||
},
|
||||
search: {
|
||||
title: 'Ապրանքների որոնում',
|
||||
placeholder: 'Մուտքագրեք ապրանքի անունը...',
|
||||
resultsCount: 'Գտնված ապրանքներ՝',
|
||||
searching: 'Որոնում...',
|
||||
retry: 'Փորձել կրկին',
|
||||
noResults: 'Ոչինչ չի գտնվել',
|
||||
noResultsFor: '"{{query}}" հարցման համար ապրանքներ չեն գտնվել',
|
||||
noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր',
|
||||
addToCart: 'Ավելացնել զամբյուղ',
|
||||
loadingMore: 'Բեռնվում է...',
|
||||
allLoaded: 'Բոլոր արդյունքները բեռնված են',
|
||||
emptyState: 'Մուտքագրեք հարցում ապրանքներ որոնելու համար',
|
||||
of: 'ից',
|
||||
title: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒л ╒╕╓А╒╕╒╢╒╕╓В╒┤',
|
||||
placeholder: '╒Д╒╕╓В╒┐╓Д╒б╒г╓А╒е╓Д ╒б╒║╓А╒б╒╢╓Д╒л ╒б╒╢╒╕╓В╒╢╒и...',
|
||||
resultsCount: '╘│╒┐╒╢╒╛╒б╒о ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А╒Э',
|
||||
searching: '╒И╓А╒╕╒╢╒╕╓В╒┤...',
|
||||
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
|
||||
noResults: '╒И╒╣╒л╒╢╒╣ ╒╣╒л ╒г╒┐╒╢╒╛╒е╒м',
|
||||
noResultsFor: '"{{query}}" ╒░╒б╓А╓Б╒┤╒б╒╢ ╒░╒б╒┤╒б╓А ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А ╒╣╒е╒╢ ╒г╒┐╒╢╒╛╒е╒м',
|
||||
noResultsHint: '╒У╒╕╓А╒▒╒е╓Д ╓Г╒╕╒н╒е╒м ╒░╒б╓А╓Б╒╕╓В╒┤╒и ╒п╒б╒┤ ╓Е╒г╒┐╒б╒г╒╕╓А╒о╒е╒м ╒б╒╡╒м ╒в╒б╒╢╒б╒м╒л ╒в╒б╒╝╒е╓А',
|
||||
addToCart: '╘▒╒╛╒е╒м╒б╓Б╒╢╒е╒м ╒ж╒б╒┤╒в╒╡╒╕╓В╒▓',
|
||||
loadingMore: '╘▓╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
|
||||
allLoaded: '╘▓╒╕╒м╒╕╓А ╒б╓А╒д╒╡╒╕╓В╒╢╓Д╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒б╒о ╒е╒╢',
|
||||
emptyState: '╒Д╒╕╓В╒┐╓Д╒б╒г╓А╒е╓Д ╒░╒б╓А╓Б╒╕╓В╒┤ ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А ╒╕╓А╒╕╒╢╒е╒м╒╕╓В ╒░╒б╒┤╒б╓А',
|
||||
of: '╒л╓Б',
|
||||
},
|
||||
category: {
|
||||
retry: 'Փորձել կրկին',
|
||||
addToCart: 'Ավելացնել զամբյուղ',
|
||||
loadingMore: 'Բեռնվում է...',
|
||||
allLoaded: 'Բոլոր ապրանքները բեռնված են',
|
||||
emptyTitle: 'Ուպս։ Այստեղ դեռ դատարկ է',
|
||||
emptyDesc: 'Այս կատեգորիայում դեռ ապրանքներ չկան, բայց շուտով կհայտնվեն',
|
||||
goHome: 'Գլխավոր էջ',
|
||||
loading: 'Ապրանքները բեռնվում են...',
|
||||
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
|
||||
addToCart: '╘▒╒╛╒е╒м╒б╓Б╒╢╒е╒м ╒ж╒б╒┤╒в╒╡╒╕╓В╒▓',
|
||||
loadingMore: '╘▓╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
|
||||
allLoaded: '╘▓╒╕╒м╒╕╓А ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒б╒о ╒е╒╢',
|
||||
emptyTitle: '╒И╓В╒║╒╜╓Й ╘▒╒╡╒╜╒┐╒е╒▓ ╒д╒е╒╝ ╒д╒б╒┐╒б╓А╒п ╒з',
|
||||
emptyDesc: '╘▒╒╡╒╜ ╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╡╒╕╓В╒┤ ╒д╒е╒╝ ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А ╒╣╒п╒б╒╢, ╒в╒б╒╡╓Б ╒╖╒╕╓В╒┐╒╕╒╛ ╒п╒░╒б╒╡╒┐╒╢╒╛╒е╒╢',
|
||||
goHome: '╘│╒м╒н╒б╒╛╒╕╓А ╒з╒╗',
|
||||
loading: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒е╒╢...',
|
||||
},
|
||||
subcategories: {
|
||||
loading: 'Ենթակատեգորիաները բեռնվում են...',
|
||||
retry: 'Փորձել կրկին',
|
||||
emptyTitle: 'Ուպս։ Ենթակատեգորիաներ դեռ չկան',
|
||||
emptyDesc: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան, բայց շուտով կհայտնվեն',
|
||||
goHome: 'Գլխավոր էջ',
|
||||
loading: '╘╡╒╢╒й╒б╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒е╒╢...',
|
||||
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
|
||||
emptyTitle: '╒И╓В╒║╒╜╓Й ╘╡╒╢╒й╒б╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А ╒д╒е╒╝ ╒╣╒п╒б╒╢',
|
||||
emptyDesc: '╘▒╒╡╒╜ ╒в╒б╒к╒╢╒╕╓В╒┤ ╒д╒е╒╝ ╒е╒╢╒й╒б╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А ╒╣╒п╒б╒╢, ╒в╒б╒╡╓Б ╒╖╒╕╓В╒┐╒╕╒╛ ╒п╒░╒б╒╡╒┐╒╢╒╛╒е╒╢',
|
||||
goHome: '╘│╒м╒н╒б╒╛╒╕╓А ╒з╒╗',
|
||||
},
|
||||
itemDetail: {
|
||||
loading: 'Բեռնվում է...',
|
||||
loadingDexar: 'Ապրանքը բեռնվում է...',
|
||||
back: 'Վերադառնալ',
|
||||
backHome: 'Վերադառնալ գլխավոր էջ',
|
||||
noImage: 'Պատկեր չկա',
|
||||
stock: 'Առկայություն՝',
|
||||
inStock: 'Առկա է',
|
||||
lowStock: 'Մնացել է քիչ',
|
||||
lastItems: 'Վերջին հատերը',
|
||||
mediumStock: 'Վերջանում է',
|
||||
addToCart: 'Ավելացնել զամբյուղ',
|
||||
description: 'Նկարագրություն',
|
||||
reviews: 'Կարծիքներ',
|
||||
yourReview: 'Ձեր կարծիքը',
|
||||
leaveReview: 'Թողնել կարծիք',
|
||||
rating: 'Գնահատական՝',
|
||||
reviewPlaceholder: 'Կիսվեք ձեր տպավորություններով ապրանքի մասին...',
|
||||
reviewPlaceholderDexar: 'Կիսվեք ձեր տպավորություններով...',
|
||||
anonymous: 'Անանուն',
|
||||
submitting: 'Ուղարկվում է...',
|
||||
submit: 'Ուղարկել',
|
||||
reviewSuccess: 'Շնորհակալություն ձեր կարծիքի համար։',
|
||||
reviewError: 'Ուղարկման սխալ։ Փորձեք ավելի ուշ։',
|
||||
defaultUser: 'Օգտատեր',
|
||||
defaultUserDexar: 'Անանուն',
|
||||
noReviews: 'Դեռ կարծիքներ չկան։ Դարձեք առաջինը։',
|
||||
qna: 'Հարցեր և պատասխաններ',
|
||||
photo: 'Լուսանկար',
|
||||
reviewsCount: 'կարծիք',
|
||||
today: 'Այսօր',
|
||||
yesterday: 'Երեկ',
|
||||
daysAgo: 'օր առաջ',
|
||||
weeksAgo: 'շաբաթ առաջ',
|
||||
loading: '╘▓╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
|
||||
loadingDexar: '╘▒╒║╓А╒б╒╢╓Д╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
|
||||
back: '╒О╒е╓А╒б╒д╒б╒╝╒╢╒б╒м',
|
||||
backHome: '╒О╒е╓А╒б╒д╒б╒╝╒╢╒б╒м ╒г╒м╒н╒б╒╛╒╕╓А ╒з╒╗',
|
||||
noImage: '╒К╒б╒┐╒п╒е╓А ╒╣╒п╒б',
|
||||
stock: '╘▒╒╝╒п╒б╒╡╒╕╓В╒й╒╡╒╕╓В╒╢╒Э',
|
||||
inStock: '╘▒╒╝╒п╒б ╒з',
|
||||
lowStock: '╒Д╒╢╒б╓Б╒е╒м ╒з ╓Д╒л╒╣',
|
||||
lastItems: '╒О╒е╓А╒╗╒л╒╢ ╒░╒б╒┐╒е╓А╒и',
|
||||
mediumStock: '╒О╒е╓А╒╗╒б╒╢╒╕╓В╒┤ ╒з',
|
||||
addToCart: '╘▒╒╛╒е╒м╒б╓Б╒╢╒е╒м ╒ж╒б╒┤╒в╒╡╒╕╓В╒▓',
|
||||
description: '╒Ж╒п╒б╓А╒б╒г╓А╒╕╓В╒й╒╡╒╕╓В╒╢',
|
||||
specifications: 'u{0532}u{0576}u{0578}u{0582}u{0569}u{0561}u{0563}u{0580}u{0565}u{0580}',
|
||||
reviews: '╘┐╒б╓А╒о╒л╓Д╒╢╒е╓А',
|
||||
yourReview: '╒Б╒е╓А ╒п╒б╓А╒о╒л╓Д╒и',
|
||||
leaveReview: '╘╣╒╕╒▓╒╢╒е╒м ╒п╒б╓А╒о╒л╓Д',
|
||||
rating: '╘│╒╢╒б╒░╒б╒┐╒б╒п╒б╒╢╒Э',
|
||||
reviewPlaceholder: '╘┐╒л╒╜╒╛╒е╓Д ╒▒╒е╓А ╒┐╒║╒б╒╛╒╕╓А╒╕╓В╒й╒╡╒╕╓В╒╢╒╢╒е╓А╒╕╒╛ ╒б╒║╓А╒б╒╢╓Д╒л ╒┤╒б╒╜╒л╒╢...',
|
||||
reviewPlaceholderDexar: '╘┐╒л╒╜╒╛╒е╓Д ╒▒╒е╓А ╒┐╒║╒б╒╛╒╕╓А╒╕╓В╒й╒╡╒╕╓В╒╢╒╢╒е╓А╒╕╒╛...',
|
||||
anonymous: '╘▒╒╢╒б╒╢╒╕╓В╒╢',
|
||||
submitting: '╒И╓В╒▓╒б╓А╒п╒╛╒╕╓В╒┤ ╒з...',
|
||||
submit: '╒И╓В╒▓╒б╓А╒п╒е╒м',
|
||||
reviewSuccess: '╒З╒╢╒╕╓А╒░╒б╒п╒б╒м╒╕╓В╒й╒╡╒╕╓В╒╢ ╒▒╒е╓А ╒п╒б╓А╒о╒л╓Д╒л ╒░╒б╒┤╒б╓А╓Й',
|
||||
reviewError: '╒И╓В╒▓╒б╓А╒п╒┤╒б╒╢ ╒╜╒н╒б╒м╓Й ╒У╒╕╓А╒▒╒е╓Д ╒б╒╛╒е╒м╒л ╒╕╓В╒╖╓Й',
|
||||
defaultUser: '╒Х╒г╒┐╒б╒┐╒е╓А',
|
||||
defaultUserDexar: '╘▒╒╢╒б╒╢╒╕╓В╒╢',
|
||||
noReviews: '╘┤╒е╒╝ ╒п╒б╓А╒о╒л╓Д╒╢╒е╓А ╒╣╒п╒б╒╢╓Й ╘┤╒б╓А╒▒╒е╓Д ╒б╒╝╒б╒╗╒л╒╢╒и╓Й',
|
||||
qna: '╒А╒б╓А╓Б╒е╓А ╓З ╒║╒б╒┐╒б╒╜╒н╒б╒╢╒╢╒е╓А',
|
||||
photo: '╘╝╒╕╓В╒╜╒б╒╢╒п╒б╓А',
|
||||
reviewsCount: '╒п╒б╓А╒о╒л╓Д',
|
||||
today: '╘▒╒╡╒╜╓Е╓А',
|
||||
yesterday: '╘╡╓А╒е╒п',
|
||||
daysAgo: '╓Е╓А ╒б╒╝╒б╒╗',
|
||||
weeksAgo: '╒╖╒б╒в╒б╒й ╒б╒╝╒б╒╗',
|
||||
},
|
||||
app: {
|
||||
connecting: 'Միացում սերվերին...',
|
||||
serverUnavailable: 'Սերվերը հասանելի չէ',
|
||||
serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետ կապը։',
|
||||
retryConnection: 'Կրկնել փորձը',
|
||||
pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս',
|
||||
connecting: '╒Д╒л╒б╓Б╒╕╓В╒┤ ╒╜╒е╓А╒╛╒е╓А╒л╒╢...',
|
||||
serverUnavailable: '╒Н╒е╓А╒╛╒е╓А╒и ╒░╒б╒╜╒б╒╢╒е╒м╒л ╒╣╒з',
|
||||
serverError: '╒Й╒░╒б╒╗╒╕╒▓╒╛╒е╓Б ╒┤╒л╒б╒╢╒б╒м ╒╜╒е╓А╒╛╒е╓А╒л╒╢╓Й ╒Н╒┐╒╕╓В╒г╒е╓Д ╒л╒╢╒┐╒е╓А╒╢╒е╒┐ ╒п╒б╒║╒и╓Й',
|
||||
retryConnection: '╘┐╓А╒п╒╢╒е╒м ╓Г╒╕╓А╒▒╒и',
|
||||
pageTitle: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒л ╓З ╒о╒б╒╝╒б╒╡╒╕╓В╒й╒╡╒╕╓В╒╢╒╢╒е╓А╒л ╒┤╒б╓А╓Д╒е╒й╓Г╒м╒е╒╡╒╜',
|
||||
},
|
||||
carousel: {
|
||||
loading: 'Ապրանքները բեռնվում են...',
|
||||
addToCart: 'Ավելացնել զամբյուղ',
|
||||
loading: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒е╒╢...',
|
||||
addToCart: '╘▒╒╛╒е╒м╒б╓Б╒╢╒е╒м ╒ж╒б╒┤╒в╒╡╒╕╓В╒▓',
|
||||
},
|
||||
common: {
|
||||
retry: 'Փորձել կրկին',
|
||||
loading: 'Բեռնվում է...',
|
||||
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
|
||||
loading: '╘▓╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
|
||||
},
|
||||
|
||||
location: {
|
||||
|
||||
@@ -148,6 +148,7 @@ export const ru: Translations = {
|
||||
mediumStock: 'Заканчивается',
|
||||
addToCart: 'Добавить в корзину',
|
||||
description: 'Описание',
|
||||
specifications: 'Характеристики',
|
||||
reviews: 'Отзывы',
|
||||
yourReview: 'Ваш отзыв',
|
||||
leaveReview: 'Оставить отзыв',
|
||||
|
||||
@@ -146,6 +146,7 @@ export interface Translations {
|
||||
mediumStock: string;
|
||||
addToCart: string;
|
||||
description: string;
|
||||
specifications: string;
|
||||
reviews: string;
|
||||
yourReview: string;
|
||||
leaveReview: string;
|
||||
|
||||
37
src/app/interceptors/api-headers.interceptor.ts
Normal file
37
src/app/interceptors/api-headers.interceptor.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { LocationService } from '../services/location.service';
|
||||
import { LanguageService } from '../services/language.service';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
/**
|
||||
* Interceptor that attaches X-Region and X-Language headers
|
||||
* to every outgoing request aimed at our API.
|
||||
*
|
||||
* The backend reads these headers to:
|
||||
* - filter catalog by region
|
||||
* - return translated content in the requested language
|
||||
*/
|
||||
export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
// Only attach headers to our own API requests
|
||||
if (!req.url.startsWith(environment.apiUrl)) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const locationService = inject(LocationService);
|
||||
const languageService = inject(LanguageService);
|
||||
|
||||
const regionId = locationService.regionId(); // '' when global
|
||||
const lang = languageService.currentLanguage(); // 'ru' | 'en' | 'hy'
|
||||
|
||||
let headers = req.headers;
|
||||
|
||||
if (regionId) {
|
||||
headers = headers.set('X-Region', regionId);
|
||||
}
|
||||
if (lang) {
|
||||
headers = headers.set('X-Language', lang);
|
||||
}
|
||||
|
||||
return next(req.clone({ headers }));
|
||||
};
|
||||
765
src/app/interceptors/mock-data.interceptor.ts
Normal file
765
src/app/interceptors/mock-data.interceptor.ts
Normal file
@@ -0,0 +1,765 @@
|
||||
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
||||
import { of, delay } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
// ─── Mock Categories (backOffice format: string IDs, img, subcategories, visible) ───
|
||||
|
||||
const MOCK_CATEGORIES = [
|
||||
{
|
||||
id: 'electronics',
|
||||
categoryID: 1,
|
||||
name: 'Электроника',
|
||||
parentID: 0,
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://images.unsplash.com/photo-1498049794561-7780e7231661?w=400&h=300&fit=crop',
|
||||
icon: 'https://images.unsplash.com/photo-1498049794561-7780e7231661?w=400&h=300&fit=crop',
|
||||
projectId: 'dexar',
|
||||
itemCount: 15,
|
||||
subcategories: [
|
||||
{
|
||||
id: 'smartphones',
|
||||
name: 'Смартфоны',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&h=300&fit=crop',
|
||||
categoryId: 'electronics',
|
||||
parentId: 'electronics',
|
||||
itemCount: 8,
|
||||
hasItems: true,
|
||||
subcategories: []
|
||||
},
|
||||
{
|
||||
id: 'laptops',
|
||||
name: 'Ноутбуки',
|
||||
visible: true,
|
||||
priority: 2,
|
||||
img: 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?w=400&h=300&fit=crop',
|
||||
categoryId: 'electronics',
|
||||
parentId: 'electronics',
|
||||
itemCount: 6,
|
||||
hasItems: true,
|
||||
subcategories: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'clothing',
|
||||
categoryID: 2,
|
||||
name: 'Одежда',
|
||||
parentID: 0,
|
||||
visible: true,
|
||||
priority: 2,
|
||||
img: 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=400&h=300&fit=crop',
|
||||
icon: 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=400&h=300&fit=crop',
|
||||
projectId: 'dexar',
|
||||
itemCount: 25,
|
||||
subcategories: [
|
||||
{
|
||||
id: 'mens',
|
||||
name: 'Мужская',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://images.unsplash.com/photo-1490578474895-699cd4e2cf59?w=400&h=300&fit=crop',
|
||||
categoryId: 'clothing',
|
||||
parentId: 'clothing',
|
||||
itemCount: 12,
|
||||
hasItems: true,
|
||||
subcategories: []
|
||||
},
|
||||
{
|
||||
id: 'womens',
|
||||
name: 'Женская',
|
||||
visible: true,
|
||||
priority: 2,
|
||||
img: 'https://images.unsplash.com/photo-1487222477894-8943e31ef7b2?w=400&h=300&fit=crop',
|
||||
categoryId: 'clothing',
|
||||
parentId: 'clothing',
|
||||
itemCount: 13,
|
||||
hasItems: true,
|
||||
subcategories: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'home',
|
||||
categoryID: 3,
|
||||
name: 'Дом и сад',
|
||||
parentID: 0,
|
||||
visible: true,
|
||||
priority: 3,
|
||||
img: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400&h=300&fit=crop',
|
||||
icon: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400&h=300&fit=crop',
|
||||
projectId: 'dexar',
|
||||
itemCount: 8,
|
||||
subcategories: []
|
||||
},
|
||||
// Subcategories as flat entries (for the legacy flat category list)
|
||||
{
|
||||
id: 'smartphones',
|
||||
categoryID: 11,
|
||||
name: 'Смартфоны',
|
||||
parentID: 1,
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&h=300&fit=crop',
|
||||
icon: 'https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&h=300&fit=crop',
|
||||
itemCount: 8
|
||||
},
|
||||
{
|
||||
id: 'laptops',
|
||||
categoryID: 12,
|
||||
name: 'Ноутбуки',
|
||||
parentID: 1,
|
||||
visible: true,
|
||||
priority: 2,
|
||||
img: 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?w=400&h=300&fit=crop',
|
||||
icon: 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?w=400&h=300&fit=crop',
|
||||
itemCount: 6
|
||||
},
|
||||
{
|
||||
id: 'mens',
|
||||
categoryID: 21,
|
||||
name: 'Мужская одежда',
|
||||
parentID: 2,
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://images.unsplash.com/photo-1490578474895-699cd4e2cf59?w=400&h=300&fit=crop',
|
||||
icon: 'https://images.unsplash.com/photo-1490578474895-699cd4e2cf59?w=400&h=300&fit=crop',
|
||||
itemCount: 12
|
||||
},
|
||||
{
|
||||
id: 'womens',
|
||||
categoryID: 22,
|
||||
name: 'Женская одежда',
|
||||
parentID: 2,
|
||||
visible: true,
|
||||
priority: 2,
|
||||
img: 'https://images.unsplash.com/photo-1487222477894-8943e31ef7b2?w=400&h=300&fit=crop',
|
||||
icon: 'https://images.unsplash.com/photo-1487222477894-8943e31ef7b2?w=400&h=300&fit=crop',
|
||||
itemCount: 13
|
||||
}
|
||||
];
|
||||
|
||||
// ─── Mock Items (backOffice format with ALL fields) ───
|
||||
|
||||
const MOCK_ITEMS: any[] = [
|
||||
{
|
||||
id: 'iphone15',
|
||||
itemID: 101,
|
||||
name: 'iPhone 15 Pro Max',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
quantity: 50,
|
||||
price: 149990,
|
||||
discount: 0,
|
||||
currency: 'RUB',
|
||||
rating: 4.8,
|
||||
remainings: 'high',
|
||||
categoryID: 11,
|
||||
imgs: [
|
||||
'https://images.unsplash.com/photo-1695048133142-1a20484d2569?w=600&h=400&fit=crop',
|
||||
'https://images.unsplash.com/photo-1592750475338-74b7b21085ab?w=600&h=400&fit=crop'
|
||||
],
|
||||
photos: [
|
||||
{ url: 'https://images.unsplash.com/photo-1695048133142-1a20484d2569?w=600&h=400&fit=crop' },
|
||||
{ url: 'https://images.unsplash.com/photo-1592750475338-74b7b21085ab?w=600&h=400&fit=crop' }
|
||||
],
|
||||
tags: ['new', 'featured', 'apple'],
|
||||
badges: ['new', 'bestseller'],
|
||||
simpleDescription: 'Новейший iPhone с титановым корпусом и чипом A17 Pro',
|
||||
description: [
|
||||
{ key: 'Цвет', value: 'Натуральный титан' },
|
||||
{ key: 'Память', value: '256 ГБ' },
|
||||
{ key: 'Дисплей', value: '6.7" Super Retina XDR' },
|
||||
{ key: 'Процессор', value: 'A17 Pro' },
|
||||
{ key: 'Камера', value: '48 Мп основная' },
|
||||
{ key: 'Аккумулятор', value: '4441 мАч' }
|
||||
],
|
||||
descriptionFields: [
|
||||
{ key: 'Цвет', value: 'Натуральный титан' },
|
||||
{ key: 'Память', value: '256 ГБ' },
|
||||
{ key: 'Дисплей', value: '6.7" Super Retina XDR' },
|
||||
{ key: 'Процессор', value: 'A17 Pro' },
|
||||
{ key: 'Камера', value: '48 Мп основная' },
|
||||
{ key: 'Аккумулятор', value: '4441 мАч' }
|
||||
],
|
||||
subcategoryId: 'smartphones',
|
||||
translations: {
|
||||
en: {
|
||||
name: 'iPhone 15 Pro Max',
|
||||
simpleDescription: 'Latest iPhone with titanium body and A17 Pro chip',
|
||||
description: [
|
||||
{ key: 'Color', value: 'Natural Titanium' },
|
||||
{ key: 'Storage', value: '256GB' },
|
||||
{ key: 'Display', value: '6.7" Super Retina XDR' },
|
||||
{ key: 'Chip', value: 'A17 Pro' },
|
||||
{ key: 'Camera', value: '48MP main' },
|
||||
{ key: 'Battery', value: '4441 mAh' }
|
||||
]
|
||||
}
|
||||
},
|
||||
comments: [
|
||||
{ id: 'c1', text: 'Отличный телефон! Камера просто огонь 🔥', author: 'Иван Петров', stars: 5, createdAt: '2025-12-15T10:30:00Z' },
|
||||
{ id: 'c2', text: 'Батарея держит весь день, очень доволен.', author: 'Мария Козлова', stars: 4, createdAt: '2026-01-05T14:20:00Z' }
|
||||
],
|
||||
callbacks: [
|
||||
{ rating: 5, content: 'Отличный телефон! Камера просто огонь 🔥', userID: 'Иван Петров', timestamp: '2025-12-15T10:30:00Z' },
|
||||
{ rating: 4, content: 'Батарея держит весь день, очень доволен.', userID: 'Мария Козлова', timestamp: '2026-01-05T14:20:00Z' }
|
||||
],
|
||||
questions: []
|
||||
},
|
||||
{
|
||||
id: 'samsung-s24',
|
||||
itemID: 102,
|
||||
name: 'Samsung Galaxy S24 Ultra',
|
||||
visible: true,
|
||||
priority: 2,
|
||||
quantity: 35,
|
||||
price: 129990,
|
||||
discount: 10,
|
||||
currency: 'RUB',
|
||||
rating: 4.6,
|
||||
remainings: 'high',
|
||||
categoryID: 11,
|
||||
imgs: [
|
||||
'https://images.unsplash.com/photo-1610945415295-d9bbf067e59c?w=600&h=400&fit=crop'
|
||||
],
|
||||
photos: [
|
||||
{ url: 'https://images.unsplash.com/photo-1610945415295-d9bbf067e59c?w=600&h=400&fit=crop' }
|
||||
],
|
||||
tags: ['new', 'android', 'samsung'],
|
||||
badges: ['new', 'sale'],
|
||||
simpleDescription: 'Премиальный флагман Samsung с S Pen',
|
||||
description: [
|
||||
{ key: 'Цвет', value: 'Титановый серый' },
|
||||
{ key: 'Память', value: '512 ГБ' },
|
||||
{ key: 'ОЗУ', value: '12 ГБ' },
|
||||
{ key: 'Дисплей', value: '6.8" Dynamic AMOLED 2X' }
|
||||
],
|
||||
descriptionFields: [
|
||||
{ key: 'Цвет', value: 'Титановый серый' },
|
||||
{ key: 'Память', value: '512 ГБ' },
|
||||
{ key: 'ОЗУ', value: '12 ГБ' },
|
||||
{ key: 'Дисплей', value: '6.8" Dynamic AMOLED 2X' }
|
||||
],
|
||||
subcategoryId: 'smartphones',
|
||||
translations: {
|
||||
en: {
|
||||
name: 'Samsung Galaxy S24 Ultra',
|
||||
simpleDescription: 'Premium Samsung flagship with S Pen',
|
||||
description: [
|
||||
{ key: 'Color', value: 'Titanium Gray' },
|
||||
{ key: 'Storage', value: '512GB' },
|
||||
{ key: 'RAM', value: '12GB' },
|
||||
{ key: 'Display', value: '6.8" Dynamic AMOLED 2X' }
|
||||
]
|
||||
}
|
||||
},
|
||||
comments: [
|
||||
{ id: 'c3', text: 'S Pen — топ, использую каждый день.', author: 'Алексей', stars: 5, createdAt: '2026-01-20T08:10:00Z' }
|
||||
],
|
||||
callbacks: [
|
||||
{ rating: 5, content: 'S Pen — топ, использую каждый день.', userID: 'Алексей', timestamp: '2026-01-20T08:10:00Z' }
|
||||
],
|
||||
questions: []
|
||||
},
|
||||
{
|
||||
id: 'pixel-8',
|
||||
itemID: 103,
|
||||
name: 'Google Pixel 8 Pro',
|
||||
visible: true,
|
||||
priority: 3,
|
||||
quantity: 20,
|
||||
price: 89990,
|
||||
discount: 15,
|
||||
currency: 'RUB',
|
||||
rating: 4.5,
|
||||
remainings: 'medium',
|
||||
categoryID: 11,
|
||||
imgs: [
|
||||
'https://images.unsplash.com/photo-1598327105666-5b89351aff97?w=600&h=400&fit=crop'
|
||||
],
|
||||
photos: [
|
||||
{ url: 'https://images.unsplash.com/photo-1598327105666-5b89351aff97?w=600&h=400&fit=crop' }
|
||||
],
|
||||
tags: ['sale', 'android', 'ai', 'google'],
|
||||
badges: ['sale', 'hot'],
|
||||
simpleDescription: 'Лучший смартфон для ИИ-фотографии',
|
||||
description: [
|
||||
{ key: 'Цвет', value: 'Bay Blue' },
|
||||
{ key: 'Память', value: '256 ГБ' },
|
||||
{ key: 'Процессор', value: 'Tensor G3' }
|
||||
],
|
||||
descriptionFields: [
|
||||
{ key: 'Цвет', value: 'Bay Blue' },
|
||||
{ key: 'Память', value: '256 ГБ' },
|
||||
{ key: 'Процессор', value: 'Tensor G3' }
|
||||
],
|
||||
subcategoryId: 'smartphones',
|
||||
translations: {},
|
||||
comments: [],
|
||||
callbacks: [],
|
||||
questions: [
|
||||
{ question: 'Поддерживает eSIM?', answer: 'Да, поддерживает dual eSIM.', upvotes: 12, downvotes: 0 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'macbook-pro',
|
||||
itemID: 104,
|
||||
name: 'MacBook Pro 16" M3 Max',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
quantity: 15,
|
||||
price: 299990,
|
||||
discount: 0,
|
||||
currency: 'RUB',
|
||||
rating: 4.9,
|
||||
remainings: 'low',
|
||||
categoryID: 12,
|
||||
imgs: [
|
||||
'https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=600&h=400&fit=crop',
|
||||
'https://images.unsplash.com/photo-1541807084-5c52b6b3adef?w=600&h=400&fit=crop'
|
||||
],
|
||||
photos: [
|
||||
{ url: 'https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=600&h=400&fit=crop' },
|
||||
{ url: 'https://images.unsplash.com/photo-1541807084-5c52b6b3adef?w=600&h=400&fit=crop' }
|
||||
],
|
||||
tags: ['featured', 'professional', 'apple'],
|
||||
badges: ['exclusive', 'limited'],
|
||||
simpleDescription: 'Мощный ноутбук для профессионалов',
|
||||
description: [
|
||||
{ key: 'Процессор', value: 'Apple M3 Max' },
|
||||
{ key: 'ОЗУ', value: '36 ГБ' },
|
||||
{ key: 'Память', value: '1 ТБ SSD' },
|
||||
{ key: 'Дисплей', value: '16.2" Liquid Retina XDR' },
|
||||
{ key: 'Батарея', value: 'До 22 ч' }
|
||||
],
|
||||
descriptionFields: [
|
||||
{ key: 'Процессор', value: 'Apple M3 Max' },
|
||||
{ key: 'ОЗУ', value: '36 ГБ' },
|
||||
{ key: 'Память', value: '1 ТБ SSD' },
|
||||
{ key: 'Дисплей', value: '16.2" Liquid Retina XDR' },
|
||||
{ key: 'Батарея', value: 'До 22 ч' }
|
||||
],
|
||||
subcategoryId: 'laptops',
|
||||
translations: {
|
||||
en: {
|
||||
name: 'MacBook Pro 16" M3 Max',
|
||||
simpleDescription: 'Powerful laptop for professionals',
|
||||
description: [
|
||||
{ key: 'Chip', value: 'Apple M3 Max' },
|
||||
{ key: 'RAM', value: '36GB' },
|
||||
{ key: 'Storage', value: '1TB SSD' },
|
||||
{ key: 'Display', value: '16.2" Liquid Retina XDR' },
|
||||
{ key: 'Battery', value: 'Up to 22h' }
|
||||
]
|
||||
}
|
||||
},
|
||||
comments: [
|
||||
{ id: 'c4', text: 'Невероятная производительность. Рендер в 3 раза быстрее.', author: 'Дизайнер Про', stars: 5, createdAt: '2025-11-15T12:00:00Z' },
|
||||
{ id: 'c5', text: 'Стоит каждого рубля. Экран — сказка.', author: 'Видеоредактор', stars: 5, createdAt: '2026-02-01T09:00:00Z' }
|
||||
],
|
||||
callbacks: [
|
||||
{ rating: 5, content: 'Невероятная производительность. Рендер в 3 раза быстрее.', userID: 'Дизайнер Про', timestamp: '2025-11-15T12:00:00Z' },
|
||||
{ rating: 5, content: 'Стоит каждого рубля. Экран — сказка.', userID: 'Видеоредактор', timestamp: '2026-02-01T09:00:00Z' }
|
||||
],
|
||||
questions: []
|
||||
},
|
||||
{
|
||||
id: 'dell-xps',
|
||||
itemID: 105,
|
||||
name: 'Dell XPS 15',
|
||||
visible: true,
|
||||
priority: 2,
|
||||
quantity: 3,
|
||||
price: 179990,
|
||||
discount: 5,
|
||||
currency: 'RUB',
|
||||
rating: 4.3,
|
||||
remainings: 'low',
|
||||
categoryID: 12,
|
||||
imgs: [
|
||||
'https://images.unsplash.com/photo-1593642702749-b7d2a804c22e?w=600&h=400&fit=crop'
|
||||
],
|
||||
photos: [
|
||||
{ url: 'https://images.unsplash.com/photo-1593642702749-b7d2a804c22e?w=600&h=400&fit=crop' }
|
||||
],
|
||||
tags: ['windows', 'professional'],
|
||||
badges: ['limited'],
|
||||
simpleDescription: 'Тонкий и мощный Windows ноутбук',
|
||||
description: [
|
||||
{ key: 'Процессор', value: 'Intel Core i9-13900H' },
|
||||
{ key: 'ОЗУ', value: '32 ГБ' },
|
||||
{ key: 'Дисплей', value: '15.6" OLED 3.5K' }
|
||||
],
|
||||
descriptionFields: [
|
||||
{ key: 'Процессор', value: 'Intel Core i9-13900H' },
|
||||
{ key: 'ОЗУ', value: '32 ГБ' },
|
||||
{ key: 'Дисплей', value: '15.6" OLED 3.5K' }
|
||||
],
|
||||
subcategoryId: 'laptops',
|
||||
translations: {},
|
||||
comments: [],
|
||||
callbacks: [],
|
||||
questions: []
|
||||
},
|
||||
{
|
||||
id: 'jacket-leather',
|
||||
itemID: 201,
|
||||
name: 'Кожаная куртка Premium',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
quantity: 8,
|
||||
price: 34990,
|
||||
discount: 20,
|
||||
currency: 'RUB',
|
||||
rating: 4.7,
|
||||
remainings: 'medium',
|
||||
categoryID: 21,
|
||||
imgs: [
|
||||
'https://images.unsplash.com/photo-1551028719-00167b16eac5?w=600&h=400&fit=crop'
|
||||
],
|
||||
photos: [
|
||||
{ url: 'https://images.unsplash.com/photo-1551028719-00167b16eac5?w=600&h=400&fit=crop' }
|
||||
],
|
||||
tags: ['leather', 'premium', 'winter'],
|
||||
badges: ['sale', 'bestseller'],
|
||||
simpleDescription: 'Стильная мужская кожаная куртка из натуральной кожи',
|
||||
description: [
|
||||
{ key: 'Материал', value: 'Натуральная кожа' },
|
||||
{ key: 'Размеры', value: 'S, M, L, XL, XXL' },
|
||||
{ key: 'Цвет', value: 'Чёрный' },
|
||||
{ key: 'Подкладка', value: 'Полиэстер 100%' }
|
||||
],
|
||||
descriptionFields: [
|
||||
{ key: 'Материал', value: 'Натуральная кожа' },
|
||||
{ key: 'Размеры', value: 'S, M, L, XL, XXL' },
|
||||
{ key: 'Цвет', value: 'Чёрный' },
|
||||
{ key: 'Подкладка', value: 'Полиэстер 100%' }
|
||||
],
|
||||
subcategoryId: 'mens',
|
||||
translations: {
|
||||
en: {
|
||||
name: 'Premium Leather Jacket',
|
||||
simpleDescription: 'Stylish men\'s genuine leather jacket',
|
||||
description: [
|
||||
{ key: 'Material', value: 'Genuine Leather' },
|
||||
{ key: 'Sizes', value: 'S, M, L, XL, XXL' },
|
||||
{ key: 'Color', value: 'Black' },
|
||||
{ key: 'Lining', value: '100% Polyester' }
|
||||
]
|
||||
}
|
||||
},
|
||||
comments: [
|
||||
{ id: 'c6', text: 'Качество кожи отличное, сидит идеально.', author: 'Антон', stars: 5, createdAt: '2026-01-10T16:30:00Z' }
|
||||
],
|
||||
callbacks: [
|
||||
{ rating: 5, content: 'Качество кожи отличное, сидит идеально.', userID: 'Антон', timestamp: '2026-01-10T16:30:00Z' }
|
||||
],
|
||||
questions: []
|
||||
},
|
||||
{
|
||||
id: 'dress-silk',
|
||||
itemID: 202,
|
||||
name: 'Шёлковое платье Elegance',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
quantity: 12,
|
||||
price: 18990,
|
||||
discount: 0,
|
||||
currency: 'RUB',
|
||||
rating: 4.9,
|
||||
remainings: 'high',
|
||||
categoryID: 22,
|
||||
imgs: [
|
||||
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=600&h=400&fit=crop'
|
||||
],
|
||||
photos: [
|
||||
{ url: 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=600&h=400&fit=crop' }
|
||||
],
|
||||
tags: ['silk', 'elegant', 'new'],
|
||||
badges: ['new', 'featured'],
|
||||
simpleDescription: 'Элегантное шёлковое платье для особых случаев',
|
||||
description: [
|
||||
{ key: 'Материал', value: '100% Шёлк' },
|
||||
{ key: 'Размеры', value: 'XS, S, M, L' },
|
||||
{ key: 'Цвет', value: 'Бордовый' },
|
||||
{ key: 'Длина', value: 'Миди' }
|
||||
],
|
||||
descriptionFields: [
|
||||
{ key: 'Материал', value: '100% Шёлк' },
|
||||
{ key: 'Размеры', value: 'XS, S, M, L' },
|
||||
{ key: 'Цвет', value: 'Бордовый' },
|
||||
{ key: 'Длина', value: 'Миди' }
|
||||
],
|
||||
subcategoryId: 'womens',
|
||||
translations: {},
|
||||
comments: [
|
||||
{ id: 'c7', text: 'Восхитительное платье! Ткань потрясающая.', author: 'Елена', stars: 5, createdAt: '2026-02-14T20:00:00Z' },
|
||||
{ id: 'c8', text: 'Идеально на вечер. Рекомендую!', author: 'Наталья', stars: 5, createdAt: '2026-02-10T11:00:00Z' }
|
||||
],
|
||||
callbacks: [
|
||||
{ rating: 5, content: 'Восхитительное платье! Ткань потрясающая.', userID: 'Елена', timestamp: '2026-02-14T20:00:00Z' },
|
||||
{ rating: 5, content: 'Идеально на вечер. Рекомендую!', userID: 'Наталья', timestamp: '2026-02-10T11:00:00Z' }
|
||||
],
|
||||
questions: []
|
||||
},
|
||||
{
|
||||
id: 'hoodie-basic',
|
||||
itemID: 203,
|
||||
name: 'Худи Oversize Basic',
|
||||
visible: true,
|
||||
priority: 3,
|
||||
quantity: 45,
|
||||
price: 5990,
|
||||
discount: 0,
|
||||
currency: 'RUB',
|
||||
rating: 4.2,
|
||||
remainings: 'high',
|
||||
categoryID: 21,
|
||||
imgs: [
|
||||
'https://images.unsplash.com/photo-1556821840-3a63f95609a7?w=600&h=400&fit=crop'
|
||||
],
|
||||
photos: [
|
||||
{ url: 'https://images.unsplash.com/photo-1556821840-3a63f95609a7?w=600&h=400&fit=crop' }
|
||||
],
|
||||
tags: ['casual', 'basic'],
|
||||
badges: [],
|
||||
simpleDescription: 'Удобное худи свободного кроя на каждый день',
|
||||
description: [
|
||||
{ key: 'Материал', value: 'Хлопок 80%, Полиэстер 20%' },
|
||||
{ key: 'Размеры', value: 'S, M, L, XL' },
|
||||
{ key: 'Цвет', value: 'Серый меланж' }
|
||||
],
|
||||
descriptionFields: [
|
||||
{ key: 'Материал', value: 'Хлопок 80%, Полиэстер 20%' },
|
||||
{ key: 'Размеры', value: 'S, M, L, XL' },
|
||||
{ key: 'Цвет', value: 'Серый меланж' }
|
||||
],
|
||||
subcategoryId: 'mens',
|
||||
translations: {},
|
||||
comments: [],
|
||||
callbacks: [],
|
||||
questions: []
|
||||
},
|
||||
{
|
||||
id: 'sneakers-run',
|
||||
itemID: 204,
|
||||
name: 'Кроссовки AirPulse Run',
|
||||
visible: true,
|
||||
priority: 2,
|
||||
quantity: 0,
|
||||
price: 12990,
|
||||
discount: 30,
|
||||
currency: 'RUB',
|
||||
rating: 4.4,
|
||||
remainings: 'out',
|
||||
categoryID: 21,
|
||||
imgs: [
|
||||
'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=600&h=400&fit=crop'
|
||||
],
|
||||
photos: [
|
||||
{ url: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=600&h=400&fit=crop' }
|
||||
],
|
||||
tags: ['sport', 'running'],
|
||||
badges: ['sale', 'hot'],
|
||||
simpleDescription: 'Лёгкие беговые кроссовки с пенной амортизацией',
|
||||
description: [
|
||||
{ key: 'Верх', value: 'Текстильная сетка' },
|
||||
{ key: 'Подошва', value: 'Пена EVA' },
|
||||
{ key: 'Вес', value: '260 г' }
|
||||
],
|
||||
descriptionFields: [
|
||||
{ key: 'Верх', value: 'Текстильная сетка' },
|
||||
{ key: 'Подошва', value: 'Пена EVA' },
|
||||
{ key: 'Вес', value: '260 г' }
|
||||
],
|
||||
subcategoryId: 'mens',
|
||||
translations: {},
|
||||
comments: [
|
||||
{ id: 'c9', text: 'Нет в наличии уже месяц... Верните!', author: 'Бегун42', stars: 3, createdAt: '2026-02-05T07:00:00Z' }
|
||||
],
|
||||
callbacks: [
|
||||
{ rating: 3, content: 'Нет в наличии уже месяц... Верните!', userID: 'Бегун42', timestamp: '2026-02-05T07:00:00Z' }
|
||||
],
|
||||
questions: []
|
||||
},
|
||||
{
|
||||
id: 'lamp-smart',
|
||||
itemID: 301,
|
||||
name: 'Умная лампа Homelight Pro',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
quantity: 100,
|
||||
price: 3990,
|
||||
discount: 0,
|
||||
currency: 'RUB',
|
||||
rating: 4.1,
|
||||
remainings: 'high',
|
||||
categoryID: 3,
|
||||
imgs: [
|
||||
'https://images.unsplash.com/photo-1507473885765-e6ed057ab6fe?w=600&h=400&fit=crop'
|
||||
],
|
||||
photos: [
|
||||
{ url: 'https://images.unsplash.com/photo-1507473885765-e6ed057ab6fe?w=600&h=400&fit=crop' }
|
||||
],
|
||||
tags: ['smart-home', 'lighting'],
|
||||
badges: ['featured'],
|
||||
simpleDescription: 'Wi-Fi лампа с управлением через приложение и голосом',
|
||||
description: [
|
||||
{ key: 'Яркость', value: '1100 лм' },
|
||||
{ key: 'Цветовая t°', value: '2700K–6500K' },
|
||||
{ key: 'Совместимость', value: 'Алиса, Google Home, Alexa' }
|
||||
],
|
||||
descriptionFields: [
|
||||
{ key: 'Яркость', value: '1100 лм' },
|
||||
{ key: 'Цветовая t°', value: '2700K–6500K' },
|
||||
{ key: 'Совместимость', value: 'Алиса, Google Home, Alexa' }
|
||||
],
|
||||
subcategoryId: 'home',
|
||||
translations: {},
|
||||
comments: [],
|
||||
callbacks: [],
|
||||
questions: []
|
||||
}
|
||||
];
|
||||
|
||||
// ─── Helper ───
|
||||
|
||||
function getAllVisibleItems(): any[] {
|
||||
return MOCK_ITEMS.filter(i => i.visible !== false);
|
||||
}
|
||||
|
||||
function getItemsByCategoryId(categoryID: number): any[] {
|
||||
return getAllVisibleItems().filter(i => i.categoryID === categoryID);
|
||||
}
|
||||
|
||||
function respond<T>(body: T, delayMs = 150) {
|
||||
return of(new HttpResponse({ status: 200, body })).pipe(delay(delayMs));
|
||||
}
|
||||
|
||||
// ─── The Interceptor ───
|
||||
|
||||
export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!(environment as any).useMockData) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const url = req.url;
|
||||
|
||||
// ── GET /ping
|
||||
if (url.endsWith('/ping') && req.method === 'GET') {
|
||||
return respond({ message: 'pong (mock)' });
|
||||
}
|
||||
|
||||
// ── GET /category (all categories flat list)
|
||||
if (url.endsWith('/category') && req.method === 'GET') {
|
||||
return respond(MOCK_CATEGORIES);
|
||||
}
|
||||
|
||||
// ── GET /category/:id (items for a category)
|
||||
const catItemsMatch = url.match(/\/category\/(\d+)$/);
|
||||
if (catItemsMatch && req.method === 'GET') {
|
||||
const catId = parseInt(catItemsMatch[1], 10);
|
||||
const items = getItemsByCategoryId(catId);
|
||||
return respond(items);
|
||||
}
|
||||
|
||||
// ── GET /item/:id
|
||||
const itemMatch = url.match(/\/item\/(\d+)$/);
|
||||
if (itemMatch && req.method === 'GET') {
|
||||
const itemId = parseInt(itemMatch[1], 10);
|
||||
const item = MOCK_ITEMS.find(i => i.itemID === itemId);
|
||||
if (item) {
|
||||
return respond(item);
|
||||
}
|
||||
return of(new HttpResponse({ status: 404, body: { error: 'Item not found' } })).pipe(delay(100));
|
||||
}
|
||||
|
||||
// ── GET /searchitems?search=...
|
||||
if (url.includes('/searchitems') && req.method === 'GET') {
|
||||
const search = req.params.get('search')?.toLowerCase() || '';
|
||||
const items = getAllVisibleItems().filter(i =>
|
||||
i.name.toLowerCase().includes(search) ||
|
||||
i.simpleDescription?.toLowerCase().includes(search) ||
|
||||
i.tags?.some((t: string) => t.toLowerCase().includes(search))
|
||||
);
|
||||
return respond({
|
||||
items,
|
||||
total: items.length,
|
||||
count: items.length,
|
||||
skip: 0
|
||||
});
|
||||
}
|
||||
|
||||
// ── GET /randomitems
|
||||
if (url.includes('/randomitems') && req.method === 'GET') {
|
||||
const count = parseInt(req.params.get('count') || '5', 10);
|
||||
const shuffled = [...getAllVisibleItems()].sort(() => Math.random() - 0.5);
|
||||
return respond(shuffled.slice(0, count));
|
||||
}
|
||||
|
||||
// ── GET /cart (return empty)
|
||||
if (url.endsWith('/cart') && req.method === 'GET') {
|
||||
return respond([]);
|
||||
}
|
||||
|
||||
// ── POST /cart (add to cart / create payment)
|
||||
if (url.endsWith('/cart') && req.method === 'POST') {
|
||||
const body = req.body as any;
|
||||
if (body?.amount) {
|
||||
// Payment mock
|
||||
return respond({
|
||||
qrId: 'mock-qr-' + Date.now(),
|
||||
qrStatus: 'CREATED',
|
||||
qrExpirationDate: new Date(Date.now() + 180000).toISOString(),
|
||||
payload: 'https://example.com/pay/mock',
|
||||
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment'
|
||||
}, 300);
|
||||
}
|
||||
return respond({ message: 'Added (mock)' });
|
||||
}
|
||||
|
||||
// ── PATCH /cart
|
||||
if (url.endsWith('/cart') && req.method === 'PATCH') {
|
||||
return respond({ message: 'Updated (mock)' });
|
||||
}
|
||||
|
||||
// ── DELETE /cart
|
||||
if (url.endsWith('/cart') && req.method === 'DELETE') {
|
||||
return respond({ message: 'Removed (mock)' });
|
||||
}
|
||||
|
||||
// ── POST /comment
|
||||
if (url.endsWith('/comment') && req.method === 'POST') {
|
||||
return respond({ message: 'Review submitted (mock)' }, 200);
|
||||
}
|
||||
|
||||
// ── POST /purchase-email
|
||||
if (url.endsWith('/purchase-email') && req.method === 'POST') {
|
||||
return respond({ message: 'Email sent (mock)' }, 200);
|
||||
}
|
||||
|
||||
// ── GET /qr/payment/:id (always return success for testing)
|
||||
if (url.includes('/qr/payment/') && req.method === 'GET') {
|
||||
return respond({
|
||||
paymentStatus: 'SUCCESS',
|
||||
code: 'SUCCESS',
|
||||
amount: 0,
|
||||
currency: 'RUB',
|
||||
qrId: 'mock',
|
||||
transactionId: 999,
|
||||
transactionDate: new Date().toISOString(),
|
||||
additionalInfo: '',
|
||||
paymentPurpose: '',
|
||||
createDate: new Date().toISOString(),
|
||||
order: 'mock-order',
|
||||
qrExpirationDate: new Date().toISOString(),
|
||||
phoneNumber: ''
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Fallback — pass through
|
||||
return next(req);
|
||||
};
|
||||
@@ -6,4 +6,28 @@ export interface Category {
|
||||
wideBanner?: string;
|
||||
itemCount?: number;
|
||||
priority?: number;
|
||||
|
||||
// BackOffice API fields
|
||||
id?: string;
|
||||
visible?: boolean;
|
||||
img?: string;
|
||||
projectId?: string;
|
||||
subcategories?: Subcategory[];
|
||||
}
|
||||
|
||||
export interface Subcategory {
|
||||
id: string;
|
||||
name: string;
|
||||
visible?: boolean;
|
||||
priority?: number;
|
||||
img?: string;
|
||||
categoryId: string;
|
||||
parentId: string;
|
||||
itemCount?: number;
|
||||
hasItems?: boolean;
|
||||
subcategories?: Subcategory[];
|
||||
}
|
||||
|
||||
export interface CategoryTranslation {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,25 @@ export interface Photo {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface DescriptionField {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id?: string;
|
||||
text: string;
|
||||
author?: string;
|
||||
stars?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ItemTranslation {
|
||||
name?: string;
|
||||
simpleDescription?: string;
|
||||
description?: DescriptionField[];
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
rating?: number;
|
||||
content?: string;
|
||||
@@ -37,6 +56,20 @@ export interface Item {
|
||||
callbacks: Review[] | null;
|
||||
questions: Question[] | null;
|
||||
partnerID?: string;
|
||||
quantity?: number;
|
||||
|
||||
// BackOffice API fields
|
||||
id?: string;
|
||||
visible?: boolean;
|
||||
priority?: number;
|
||||
imgs?: string[];
|
||||
tags?: string[];
|
||||
badges?: string[];
|
||||
simpleDescription?: string;
|
||||
descriptionFields?: DescriptionField[];
|
||||
subcategoryId?: string;
|
||||
translations?: Record<string, ItemTranslation>;
|
||||
comments?: Comment[];
|
||||
}
|
||||
|
||||
export interface CartItem extends Item {
|
||||
|
||||
@@ -44,7 +44,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="item-description">{{ item.description.substring(0, 100) }}...</p>
|
||||
<p class="item-description">{{ item.simpleDescription || item?.description?.substring?.(0, 100) || '' }}...</p>
|
||||
|
||||
@if (item.badges && item.badges.length > 0) {
|
||||
<div class="cart-item-badges">
|
||||
@for (badge of item.badges; track badge) {
|
||||
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="item-footer">
|
||||
<div class="item-pricing">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { interval, Subscription } from 'rxjs';
|
||||
import { switchMap, take } from 'rxjs/operators';
|
||||
import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass } from '../../utils/item.utils';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
@@ -129,6 +129,7 @@ export class CartComponent implements OnDestroy {
|
||||
readonly getMainImage = getMainImage;
|
||||
readonly trackByItemId = trackByItemId;
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
|
||||
checkout(): void {
|
||||
if (!this.termsAccepted) {
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
@if (item.discount > 0) {
|
||||
<div class="discount-badge">-{{ item.discount }}%</div>
|
||||
}
|
||||
@if (item.badges && item.badges.length > 0) {
|
||||
<div class="item-badges-overlay">
|
||||
@for (badge of item.badges; track badge) {
|
||||
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="item-details">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ApiService, CartService } from '../../services';
|
||||
import { Item } from '../../models';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass } from '../../utils/item.utils';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
|
||||
@@ -107,4 +107,5 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getMainImage = getMainImage;
|
||||
readonly trackByItemId = trackByItemId;
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,23 @@
|
||||
</div>
|
||||
|
||||
<div class="novo-info">
|
||||
<h1 class="novo-title">{{ item()!.name }}</h1>
|
||||
<h1 class="novo-title">{{ getItemName() }}</h1>
|
||||
|
||||
@if (item()!.badges && item()!.badges!.length > 0) {
|
||||
<div class="novo-badges">
|
||||
@for (badge of item()!.badges!; track badge) {
|
||||
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (item()!.tags && item()!.tags!.length > 0) {
|
||||
<div class="novo-tags">
|
||||
@for (tag of item()!.tags!; track tag) {
|
||||
<span class="item-tag">#{{ tag }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="novo-rating">
|
||||
<span class="stars">{{ getRatingStars(item()!.rating) }}</span>
|
||||
@@ -77,10 +93,13 @@
|
||||
|
||||
<div class="novo-stock">
|
||||
<span class="stock-label">{{ 'itemDetail.stock' | translate }}</span>
|
||||
<div class="stock-indicator" [class.high]="item()!.remainings === 'high'" [class.medium]="item()!.remainings === 'medium'" [class.low]="item()!.remainings === 'low'">
|
||||
<div class="stock-indicator" [class]="getStockClass()">
|
||||
<span class="dot"></span>
|
||||
{{ item()!.remainings === 'high' ? ('itemDetail.inStock' | translate) : item()!.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lowStock' | translate) }}
|
||||
{{ getStockLabel() }}
|
||||
</div>
|
||||
@if (item()!.quantity != null) {
|
||||
<span class="stock-qty">({{ item()!.quantity }} шт.)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="novo-add-cart" (click)="addToCart()">
|
||||
@@ -93,8 +112,26 @@
|
||||
</button>
|
||||
|
||||
<div class="novo-description">
|
||||
<h3>{{ 'itemDetail.description' | translate }}</h3>
|
||||
<div [innerHTML]="getSafeHtml(item()!.description)"></div>
|
||||
@if (getSimpleDescription()) {
|
||||
<p class="novo-simple-desc">{{ getSimpleDescription() }}</p>
|
||||
}
|
||||
|
||||
@if (hasDescriptionFields()) {
|
||||
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
|
||||
<table class="novo-specs-table">
|
||||
<tbody>
|
||||
@for (field of getTranslatedDescriptionFields(); track field.key) {
|
||||
<tr>
|
||||
<td class="spec-key">{{ field.key }}</td>
|
||||
<td class="spec-value">{{ field.value }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<h3>{{ 'itemDetail.description' | translate }}</h3>
|
||||
<div [innerHTML]="getSafeHtml(item()!.description)"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,7 +286,23 @@
|
||||
|
||||
<!-- Item Info -->
|
||||
<div class="dx-info">
|
||||
<h1 class="dx-title">{{ item()!.name }}</h1>
|
||||
<h1 class="dx-title">{{ getItemName() }}</h1>
|
||||
|
||||
@if (item()!.badges && item()!.badges!.length > 0) {
|
||||
<div class="dx-badges">
|
||||
@for (badge of item()!.badges!; track badge) {
|
||||
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (item()!.tags && item()!.tags!.length > 0) {
|
||||
<div class="dx-tags">
|
||||
@for (tag of item()!.tags!; track tag) {
|
||||
<span class="item-tag">#{{ tag }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="dx-rating">
|
||||
<div class="dx-stars">
|
||||
@@ -277,13 +330,13 @@
|
||||
|
||||
<div class="dx-stock">
|
||||
<span class="dx-stock-label">{{ 'itemDetail.stock' | translate }}</span>
|
||||
<span class="dx-stock-status"
|
||||
[class.high]="item()!.remainings === 'high'"
|
||||
[class.medium]="item()!.remainings === 'medium'"
|
||||
[class.low]="item()!.remainings === 'low'">
|
||||
<span class="dx-stock-status" [class]="getStockClass()">
|
||||
<span class="dx-stock-dot"></span>
|
||||
{{ item()!.remainings === 'high' ? ('itemDetail.inStock' | translate) : item()!.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lastItems' | translate) }}
|
||||
{{ getStockLabel() }}
|
||||
</span>
|
||||
@if (item()!.quantity != null) {
|
||||
<span class="dx-stock-qty">({{ item()!.quantity }} шт.)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="dx-add-cart" (click)="addToCart()">
|
||||
@@ -296,8 +349,26 @@
|
||||
</button>
|
||||
|
||||
<div class="dx-description">
|
||||
<h2>{{ 'itemDetail.description' | translate }}</h2>
|
||||
<div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div>
|
||||
@if (getSimpleDescription()) {
|
||||
<p class="dx-simple-desc">{{ getSimpleDescription() }}</p>
|
||||
}
|
||||
|
||||
@if (hasDescriptionFields()) {
|
||||
<h2>{{ 'itemDetail.specifications' | translate }}</h2>
|
||||
<table class="dx-specs-table">
|
||||
<tbody>
|
||||
@for (field of getTranslatedDescriptionFields(); track field.key) {
|
||||
<tr>
|
||||
<td class="spec-key">{{ field.key }}</td>
|
||||
<td class="spec-value">{{ field.value }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<h2>{{ 'itemDetail.description' | translate }}</h2>
|
||||
<div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -642,22 +642,70 @@ $dx-card-bg: #f5f3f9;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
// ========== DEXAR RESPONSIVE ==========
|
||||
|
||||
// Large desktop — constrain gallery height
|
||||
@media (min-width: 1201px) {
|
||||
.dx-main-photo {
|
||||
max-height: 560px;
|
||||
}
|
||||
}
|
||||
|
||||
// Tablet landscape / small desktop
|
||||
@media (max-width: 1200px) {
|
||||
.dx-item-content {
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.dx-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Tablet portrait
|
||||
@media (max-width: 992px) {
|
||||
.dx-item-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.dx-gallery {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dx-main-photo {
|
||||
max-height: 480px;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.dx-add-cart {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dx-reviews-section,
|
||||
.dx-qa-section {
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile
|
||||
@media (max-width: 768px) {
|
||||
.dx-item-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// On mobile: thumbnails go below main photo
|
||||
.dx-item-content {
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
// Thumbnails go below main photo
|
||||
.dx-gallery {
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dx-thumbnails {
|
||||
@@ -666,14 +714,16 @@ $dx-card-bg: #f5f3f9;
|
||||
max-height: none;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
order: 1; // put below main photo
|
||||
order: 1;
|
||||
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.dx-main-photo {
|
||||
order: 0; // main photo first
|
||||
order: 0;
|
||||
max-height: 400px;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.dx-thumb {
|
||||
@@ -690,8 +740,32 @@ $dx-card-bg: #f5f3f9;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.dx-old-price {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dx-add-cart {
|
||||
max-width: 100%;
|
||||
padding: 14px 20px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dx-description {
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dx-specs-table {
|
||||
.spec-key {
|
||||
white-space: normal;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dx-review-form {
|
||||
@@ -707,21 +781,153 @@ $dx-card-bg: #f5f3f9;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.dx-review-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dx-reviews-section,
|
||||
.dx-qa-section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dx-qa-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dx-question,
|
||||
.dx-answer {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Small mobile
|
||||
@media (max-width: 480px) {
|
||||
.dx-item-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dx-item-content {
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dx-main-photo {
|
||||
max-height: 300px;
|
||||
border-radius: 10px;
|
||||
|
||||
img, video {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.dx-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 56px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.dx-title {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.dx-info {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dx-current-price {
|
||||
font-size: 1.6rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.dx-rating {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dx-stock {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dx-add-cart {
|
||||
padding: 12px 16px;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dx-review-form {
|
||||
padding: 14px;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dx-star-selector {
|
||||
.dx-star-pick {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dx-textarea {
|
||||
padding: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dx-review-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dx-reviewer-name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dx-review-text {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dx-specs-table {
|
||||
td {
|
||||
padding: 6px 8px;
|
||||
font-size: 0.8rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spec-key {
|
||||
width: 100%;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.spec-value {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
tr {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dx-qa-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dx-question,
|
||||
.dx-answer {
|
||||
font-size: 0.85rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dx-qa-label {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1299,12 +1505,20 @@ $dx-card-bg: #f5f3f9;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== NOVO RESPONSIVE ==========
|
||||
|
||||
// Tablet portrait
|
||||
@media (max-width: 968px) {
|
||||
.novo-item-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.novo-gallery {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.novo-info .novo-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -1313,6 +1527,10 @@ $dx-card-bg: #f5f3f9;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.novo-info .novo-add-cart {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.novo-review-form {
|
||||
padding: 1.5rem;
|
||||
|
||||
@@ -1327,3 +1545,302 @@ $dx-card-bg: #f5f3f9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile
|
||||
@media (max-width: 768px) {
|
||||
.novo-item-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.novo-item-content {
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.novo-gallery {
|
||||
max-width: 100%;
|
||||
|
||||
.novo-main-photo {
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.novo-thumbnails {
|
||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.novo-info {
|
||||
.novo-title {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.novo-rating {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.novo-price-block {
|
||||
padding: 1rem;
|
||||
|
||||
.current-price {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.old-price {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.novo-stock {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.novo-add-cart {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.novo-description {
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.novo-specs-table {
|
||||
.spec-key {
|
||||
white-space: normal;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.novo-reviews {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.35rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.novo-review-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.novo-review-form {
|
||||
padding: 1.25rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small mobile
|
||||
@media (max-width: 480px) {
|
||||
.novo-item-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.novo-item-content {
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.novo-gallery {
|
||||
.novo-thumbnails {
|
||||
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.novo-main-photo {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
|
||||
.novo-info {
|
||||
.novo-title {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.novo-price-block {
|
||||
padding: 0.75rem;
|
||||
|
||||
.current-price {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.novo-stock {
|
||||
padding: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.novo-add-cart {
|
||||
padding: 0.85rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
|
||||
.novo-review-form {
|
||||
padding: 1rem;
|
||||
|
||||
.novo-rating-input {
|
||||
.novo-star-selector {
|
||||
.novo-star {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.novo-textarea {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.novo-review-card {
|
||||
padding: 0.75rem;
|
||||
|
||||
.review-header {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.review-stars {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.review-text {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.novo-specs-table {
|
||||
td {
|
||||
padding: 6px 8px;
|
||||
font-size: 0.8rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spec-key {
|
||||
width: 100%;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.spec-value {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
tr {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== BADGES, TAGS & SPECS (shared) ==========
|
||||
|
||||
// Badges
|
||||
.novo-badges, .dx-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #fff;
|
||||
|
||||
&.badge-new { background: #4caf50; }
|
||||
&.badge-sale { background: #f44336; }
|
||||
&.badge-exclusive { background: #9c27b0; }
|
||||
&.badge-hot { background: #ff5722; }
|
||||
&.badge-limited { background: #ff9800; }
|
||||
&.badge-bestseller { background: #2196f3; }
|
||||
&.badge-featured { background: #607d8b; }
|
||||
&.badge-custom { background: #78909c; }
|
||||
}
|
||||
|
||||
// Tags
|
||||
.novo-tags, .dx-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 6px 0 12px;
|
||||
}
|
||||
|
||||
.item-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: #497671;
|
||||
background: rgba(73, 118, 113, 0.08);
|
||||
border: 1px solid rgba(73, 118, 113, 0.15);
|
||||
}
|
||||
|
||||
// Specs table
|
||||
.novo-specs-table, .dx-specs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid #e8ecec;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.spec-key {
|
||||
color: #697777;
|
||||
font-weight: 500;
|
||||
width: 40%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spec-value {
|
||||
color: #1e3c38;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple description
|
||||
.novo-simple-desc, .dx-simple-desc {
|
||||
font-size: 0.95rem;
|
||||
color: #697777;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// Stock quantity
|
||||
.stock-qty, .dx-stock-qty {
|
||||
font-size: 0.8rem;
|
||||
color: #697777;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject }
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ApiService, CartService, TelegramService, SeoService } from '../../services';
|
||||
import { Item } from '../../models';
|
||||
import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services';
|
||||
import { Item, DescriptionField } from '../../models';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { SecurityContext } from '@angular/core';
|
||||
import { getDiscountedPrice } from '../../utils/item.utils';
|
||||
import { getDiscountedPrice, getAllImages, getStockStatus, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
@@ -48,7 +48,8 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
||||
private apiService: ApiService,
|
||||
private cartService: CartService,
|
||||
private telegramService: TelegramService,
|
||||
private sanitizer: DomSanitizer
|
||||
private sanitizer: DomSanitizer,
|
||||
private languageService: LanguageService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -100,6 +101,57 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
||||
return getDiscountedPrice(currentItem);
|
||||
}
|
||||
|
||||
// BackOffice integration helpers
|
||||
|
||||
getItemName(): string {
|
||||
const currentItem = this.item();
|
||||
if (!currentItem) return '';
|
||||
const lang = this.languageService.currentLanguage();
|
||||
return getTranslatedField(currentItem, 'name', lang);
|
||||
}
|
||||
|
||||
getSimpleDescription(): string {
|
||||
const currentItem = this.item();
|
||||
if (!currentItem) return '';
|
||||
const lang = this.languageService.currentLanguage();
|
||||
return getTranslatedField(currentItem, 'simpleDescription', lang);
|
||||
}
|
||||
|
||||
hasDescriptionFields(): boolean {
|
||||
const currentItem = this.item();
|
||||
return !!(currentItem?.descriptionFields && currentItem.descriptionFields.length > 0);
|
||||
}
|
||||
|
||||
getTranslatedDescriptionFields(): DescriptionField[] {
|
||||
const currentItem = this.item();
|
||||
if (!currentItem) return [];
|
||||
const lang = this.languageService.currentLanguage();
|
||||
const translation = currentItem.translations?.[lang];
|
||||
if (translation?.description && translation.description.length > 0) {
|
||||
return translation.description;
|
||||
}
|
||||
return currentItem.descriptionFields || [];
|
||||
}
|
||||
|
||||
getStockClass(): string {
|
||||
const currentItem = this.item();
|
||||
if (!currentItem) return 'high';
|
||||
return getStockStatus(currentItem);
|
||||
}
|
||||
|
||||
getStockLabel(): string {
|
||||
const status = this.getStockClass();
|
||||
switch (status) {
|
||||
case 'high': return 'В наличии';
|
||||
case 'medium': return 'Заканчивается';
|
||||
case 'low': return 'Последние штуки';
|
||||
case 'out': return 'Нет в наличии';
|
||||
default: return 'В наличии';
|
||||
}
|
||||
}
|
||||
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
|
||||
getSafeHtml(html: string): SafeHtml {
|
||||
return this.sanitizer.sanitize(SecurityContext.HTML, html) || '';
|
||||
}
|
||||
|
||||
@@ -63,11 +63,22 @@
|
||||
@if (item.discount > 0) {
|
||||
<div class="discount-badge">-{{ item.discount }}%</div>
|
||||
}
|
||||
@if (item.badges && item.badges.length > 0) {
|
||||
<div class="item-badges-overlay">
|
||||
@for (badge of item.badges; track badge) {
|
||||
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="item-details">
|
||||
<h3 class="item-name">{{ item.name }}</h3>
|
||||
|
||||
@if (item.simpleDescription) {
|
||||
<p class="item-simple-desc">{{ item.simpleDescription }}</p>
|
||||
}
|
||||
|
||||
<div class="item-rating">
|
||||
<span class="rating-stars">⭐ {{ item.rating }}</span>
|
||||
<span class="rating-count">({{ item.callbacks?.length || 0 }})</span>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ApiService, CartService } from '../../services';
|
||||
import { Item } from '../../models';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass } from '../../utils/item.utils';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
@@ -136,4 +136,5 @@ export class SearchComponent implements OnDestroy {
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getMainImage = getMainImage;
|
||||
readonly trackByItemId = trackByItemId;
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
}
|
||||
|
||||
@@ -1,71 +1,160 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Category, Item } from '../models';
|
||||
import { Category, Item, Subcategory } from '../models';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { LocationService } from './location.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly baseUrl = environment.apiUrl;
|
||||
private locationService = inject(LocationService);
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
private normalizeItem(item: Item): Item {
|
||||
return {
|
||||
...item,
|
||||
remainings: item.remainings || 'high'
|
||||
};
|
||||
/**
|
||||
* Normalize an item from the API response — supports both
|
||||
* legacy marketplace format and the new backOffice API format.
|
||||
*/
|
||||
private normalizeItem(raw: any): Item {
|
||||
const item: Item = { ...raw };
|
||||
|
||||
// Map backOffice string id → legacy numeric itemID
|
||||
if (raw.id != null && raw.itemID == null) {
|
||||
item.id = String(raw.id);
|
||||
item.itemID = typeof raw.id === 'number' ? raw.id : 0;
|
||||
}
|
||||
|
||||
// Map backOffice imgs[] → legacy photos[]
|
||||
if (raw.imgs && (!raw.photos || raw.photos.length === 0)) {
|
||||
item.photos = raw.imgs.map((url: string) => ({ url }));
|
||||
}
|
||||
item.imgs = raw.imgs || raw.photos?.map((p: any) => p.url) || [];
|
||||
|
||||
// Map backOffice description (key-value array) → legacy description string
|
||||
if (Array.isArray(raw.description)) {
|
||||
item.descriptionFields = raw.description;
|
||||
item.description = raw.description.map((d: any) => `${d.key}: ${d.value}`).join('\n');
|
||||
} else {
|
||||
item.description = raw.description || raw.simpleDescription || '';
|
||||
}
|
||||
|
||||
// Map backOffice comments → legacy callbacks
|
||||
if (raw.comments && (!raw.callbacks || raw.callbacks.length === 0)) {
|
||||
item.callbacks = raw.comments.map((c: any) => ({
|
||||
rating: c.stars,
|
||||
content: c.text,
|
||||
userID: c.author,
|
||||
timestamp: c.createdAt,
|
||||
}));
|
||||
}
|
||||
item.comments = raw.comments || raw.callbacks?.map((c: any) => ({
|
||||
id: c.userID,
|
||||
text: c.content,
|
||||
author: c.userID,
|
||||
stars: c.rating,
|
||||
createdAt: c.timestamp,
|
||||
})) || [];
|
||||
|
||||
// Compute average rating from comments if not present
|
||||
if (raw.rating == null && item.comments && item.comments.length > 0) {
|
||||
const rated = item.comments.filter(c => c.stars != null);
|
||||
item.rating = rated.length > 0
|
||||
? rated.reduce((sum, c) => sum + (c.stars || 0), 0) / rated.length
|
||||
: 0;
|
||||
}
|
||||
item.rating = item.rating || 0;
|
||||
|
||||
// Defaults
|
||||
item.discount = item.discount || 0;
|
||||
item.remainings = item.remainings || (raw.quantity != null
|
||||
? (raw.quantity <= 0 ? 'out' : raw.quantity <= 5 ? 'low' : raw.quantity <= 20 ? 'medium' : 'high')
|
||||
: 'high');
|
||||
item.currency = item.currency || 'RUB';
|
||||
|
||||
// Preserve new backOffice fields
|
||||
item.badges = raw.badges || [];
|
||||
item.tags = raw.tags || [];
|
||||
item.simpleDescription = raw.simpleDescription || '';
|
||||
item.translations = raw.translations || {};
|
||||
item.visible = raw.visible ?? true;
|
||||
item.priority = raw.priority ?? 0;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private normalizeItems(items: Item[] | null | undefined): Item[] {
|
||||
private normalizeItems(items: any[] | null | undefined): Item[] {
|
||||
if (!items || !Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
return items.map(item => this.normalizeItem(item));
|
||||
}
|
||||
|
||||
/** Append region query param if a region is selected */
|
||||
private withRegion(params: HttpParams = new HttpParams()): HttpParams {
|
||||
const regionId = this.locationService.regionId();
|
||||
return regionId ? params.set('region', regionId) : params;
|
||||
/**
|
||||
* Normalize a category from the API response — supports both
|
||||
* the flat legacy format and nested backOffice format.
|
||||
*/
|
||||
private normalizeCategory(raw: any): Category {
|
||||
const cat: Category = { ...raw };
|
||||
|
||||
if (raw.id != null && raw.categoryID == null) {
|
||||
cat.id = String(raw.id);
|
||||
cat.categoryID = typeof raw.id === 'number' ? raw.id : 0;
|
||||
}
|
||||
|
||||
// Map backOffice img → legacy icon
|
||||
if (raw.img && !raw.icon) {
|
||||
cat.icon = raw.img;
|
||||
}
|
||||
cat.img = raw.img || raw.icon;
|
||||
|
||||
cat.parentID = raw.parentID ?? 0;
|
||||
cat.visible = raw.visible ?? true;
|
||||
cat.priority = raw.priority ?? 0;
|
||||
|
||||
if (raw.subcategories && Array.isArray(raw.subcategories)) {
|
||||
cat.subcategories = raw.subcategories;
|
||||
}
|
||||
|
||||
return cat;
|
||||
}
|
||||
|
||||
private normalizeCategories(cats: any[] | null | undefined): Category[] {
|
||||
if (!cats || !Array.isArray(cats)) return [];
|
||||
return cats.map(c => this.normalizeCategory(c));
|
||||
}
|
||||
|
||||
// ─── Core Marketplace Endpoints ───────────────────────────
|
||||
|
||||
ping(): Observable<{ message: string }> {
|
||||
return this.http.get<{ message: string }>(`${this.baseUrl}/ping`);
|
||||
}
|
||||
|
||||
getCategories(): Observable<Category[]> {
|
||||
return this.http.get<Category[]>(`${this.baseUrl}/category`, { params: this.withRegion() });
|
||||
return this.http.get<any[]>(`${this.baseUrl}/category`)
|
||||
.pipe(map(cats => this.normalizeCategories(cats)));
|
||||
}
|
||||
|
||||
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
|
||||
const params = this.withRegion(
|
||||
new HttpParams()
|
||||
.set('count', count.toString())
|
||||
.set('skip', skip.toString())
|
||||
);
|
||||
return this.http.get<Item[]>(`${this.baseUrl}/category/${categoryID}`, { params })
|
||||
const params = new HttpParams()
|
||||
.set('count', count.toString())
|
||||
.set('skip', skip.toString());
|
||||
return this.http.get<any[]>(`${this.baseUrl}/category/${categoryID}`, { params })
|
||||
.pipe(map(items => this.normalizeItems(items)));
|
||||
}
|
||||
|
||||
getItem(itemID: number): Observable<Item> {
|
||||
return this.http.get<Item>(`${this.baseUrl}/item/${itemID}`, { params: this.withRegion() })
|
||||
return this.http.get<any>(`${this.baseUrl}/item/${itemID}`)
|
||||
.pipe(map(item => this.normalizeItem(item)));
|
||||
}
|
||||
|
||||
searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> {
|
||||
const params = this.withRegion(
|
||||
new HttpParams()
|
||||
.set('search', search)
|
||||
.set('count', count.toString())
|
||||
.set('skip', skip.toString())
|
||||
);
|
||||
return this.http.get<{ items: Item[], total: number, count: number, skip: number }>(`${this.baseUrl}/searchitems`, { params })
|
||||
const params = new HttpParams()
|
||||
.set('search', search)
|
||||
.set('count', count.toString())
|
||||
.set('skip', skip.toString());
|
||||
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
|
||||
.pipe(
|
||||
map(response => ({
|
||||
items: this.normalizeItems(response?.items || []),
|
||||
@@ -87,7 +176,7 @@ export class ApiService {
|
||||
}
|
||||
|
||||
getCart(): Observable<Item[]> {
|
||||
return this.http.get<Item[]>(`${this.baseUrl}/cart`)
|
||||
return this.http.get<any[]>(`${this.baseUrl}/cart`)
|
||||
.pipe(map(items => this.normalizeItems(items)));
|
||||
}
|
||||
|
||||
@@ -169,11 +258,11 @@ export class ApiService {
|
||||
}
|
||||
|
||||
getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> {
|
||||
let params = this.withRegion(new HttpParams().set('count', count.toString()));
|
||||
let params = new HttpParams().set('count', count.toString());
|
||||
if (categoryID) {
|
||||
params = params.set('category', categoryID.toString());
|
||||
}
|
||||
return this.http.get<Item[]>(`${this.baseUrl}/randomitems`, { params })
|
||||
return this.http.get<any[]>(`${this.baseUrl}/randomitems`, { params })
|
||||
.pipe(map(items => this.normalizeItems(items)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,78 @@
|
||||
import { Item } from '../models';
|
||||
|
||||
export function getDiscountedPrice(item: Item): number {
|
||||
return item.price * (1 - item.discount / 100);
|
||||
return item.price * (1 - (item.discount || 0) / 100);
|
||||
}
|
||||
|
||||
export function getMainImage(item: Item): string {
|
||||
// Support both backOffice format (imgs: string[]) and legacy (photos: Photo[])
|
||||
if (item.imgs && item.imgs.length > 0) {
|
||||
return item.imgs[0];
|
||||
}
|
||||
return item.photos?.[0]?.url || '/assets/images/placeholder.svg';
|
||||
}
|
||||
|
||||
export function trackByItemId(index: number, item: Item): number {
|
||||
return item.itemID;
|
||||
export function getAllImages(item: Item): string[] {
|
||||
if (item.imgs && item.imgs.length > 0) {
|
||||
return item.imgs;
|
||||
}
|
||||
return item.photos?.map(p => p.url) || [];
|
||||
}
|
||||
|
||||
export function trackByItemId(index: number, item: Item): number | string {
|
||||
return item.id || item.itemID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display description — supports both legacy HTML string
|
||||
* and structured key-value pairs from backOffice API.
|
||||
*/
|
||||
export function hasStructuredDescription(item: Item): boolean {
|
||||
return Array.isArray(item.descriptionFields) && item.descriptionFields.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute stock status from quantity if the legacy `remainings` field is absent.
|
||||
*/
|
||||
export function getStockStatus(item: Item): string {
|
||||
if (item.remainings) return item.remainings;
|
||||
if (item.quantity == null) return 'high';
|
||||
if (item.quantity <= 0) return 'out';
|
||||
if (item.quantity <= 5) return 'low';
|
||||
if (item.quantity <= 20) return 'medium';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map backOffice badge names to CSS color classes.
|
||||
*/
|
||||
export function getBadgeClass(badge: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'new': 'badge-new',
|
||||
'sale': 'badge-sale',
|
||||
'exclusive': 'badge-exclusive',
|
||||
'hot': 'badge-hot',
|
||||
'limited': 'badge-limited',
|
||||
'bestseller': 'badge-bestseller',
|
||||
'featured': 'badge-featured',
|
||||
};
|
||||
return map[badge.toLowerCase()] || 'badge-custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translated name/description for the current language.
|
||||
* Falls back to the default (base) field if no translation exists.
|
||||
*/
|
||||
export function getTranslatedField(
|
||||
item: Item,
|
||||
field: 'name' | 'simpleDescription',
|
||||
lang: string
|
||||
): string {
|
||||
const translation = item.translations?.[lang];
|
||||
if (translation && translation[field]) {
|
||||
return translation[field]!;
|
||||
}
|
||||
if (field === 'name') return item.name;
|
||||
if (field === 'simpleDescription') return item.simpleDescription || item.description || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Dexar Market Configuration
|
||||
export const environment = {
|
||||
production: false,
|
||||
useMockData: true, // Toggle to test with backOffice mock data
|
||||
brandName: 'Dexarmarket',
|
||||
brandFullName: 'Dexar Market',
|
||||
theme: 'dexar',
|
||||
|
||||
@@ -140,3 +140,58 @@ a, button, input, textarea, select {
|
||||
.p-3 { padding: 1.5rem; }
|
||||
.p-4 { padding: 2rem; }
|
||||
|
||||
// ─── Shared Badge & Tag Styles (from backOffice integration) ───
|
||||
|
||||
.item-badges-overlay {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
|
||||
&.badge-new { background: #4caf50; }
|
||||
&.badge-sale { background: #f44336; }
|
||||
&.badge-exclusive { background: #9c27b0; }
|
||||
&.badge-hot { background: #ff5722; }
|
||||
&.badge-limited { background: #ff9800; }
|
||||
&.badge-bestseller { background: #2196f3; }
|
||||
&.badge-featured { background: #607d8b; }
|
||||
&.badge-custom { background: #78909c; }
|
||||
}
|
||||
|
||||
.item-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--primary-color);
|
||||
background: rgba(73, 118, 113, 0.08);
|
||||
border: 1px solid rgba(73, 118, 113, 0.15);
|
||||
}
|
||||
|
||||
.item-simple-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin: 2px 0 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user