Compare commits
9 Commits
dcb75b8f4e
...
back-offic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56f4c56b9e | ||
|
|
3445f55758 | ||
|
|
350581cbe9 | ||
|
|
377da22761 | ||
|
|
6689acbe57 | ||
|
|
421346d957 | ||
|
|
86d11364f0 | ||
|
|
d6097e2b5d | ||
|
|
369af40f20 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
|||||||
/out-tsc
|
/out-tsc
|
||||||
/bazel-out
|
/bazel-out
|
||||||
/files
|
/files
|
||||||
|
changes.txt
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|||||||
@@ -154,7 +154,8 @@
|
|||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"options": {
|
"options": {
|
||||||
"allowedHosts": ["novo.market", "dexarmarket.ru", "localhost"]
|
"allowedHosts": ["novo.market", "dexarmarket.ru", "localhost"],
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
},
|
},
|
||||||
"builder": "@angular/build:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
|||||||
@@ -1,168 +1,726 @@
|
|||||||
# Backend API Changes Required
|
# Complete Backend API Documentation
|
||||||
|
|
||||||
## Cart Quantity Support
|
> **Last updated:** February 2026
|
||||||
|
> **Frontend:** Angular 21 · Dual-brand (Dexar + Novo)
|
||||||
### 1. Add Quantity to Cart Items
|
> **Covers:** Catalog, Cart, Payments, Reviews, Regions, Auth, i18n, BackOffice
|
||||||
|
|
||||||
**Current GET /cart Response:**
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"itemID": 123,
|
|
||||||
"name": "Product Name",
|
|
||||||
"price": 100,
|
|
||||||
"currency": "RUB",
|
|
||||||
...other item fields
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**NEW Required Response:**
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"itemID": 123,
|
|
||||||
"name": "Product Name",
|
|
||||||
"price": 100,
|
|
||||||
"currency": "RUB",
|
|
||||||
"quantity": 2, // <-- ADD THIS FIELD
|
|
||||||
...other item fields
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. POST /cart - Add Item to Cart
|
|
||||||
|
|
||||||
**Current Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"itemID": 123
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**NEW Request (with optional quantity):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"itemID": 123,
|
|
||||||
"quantity": 1 // Optional, defaults to 1 if not provided
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- If item already exists in cart, **increment** the quantity by the provided amount
|
|
||||||
- If item doesn't exist, add it with the specified quantity
|
|
||||||
|
|
||||||
### 3. PATCH /cart - Update Item Quantity (NEW ENDPOINT)
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"itemID": 123,
|
|
||||||
"quantity": 5 // New quantity value (not increment, but absolute value)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Cart updated successfully"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- Set the quantity to the exact value provided
|
|
||||||
- If quantity is 0 or negative, remove the item from cart
|
|
||||||
|
|
||||||
### 4. Payment Endpoints - Include Quantity
|
|
||||||
|
|
||||||
**POST /payment/create**
|
|
||||||
|
|
||||||
Update the items array to include quantity:
|
|
||||||
|
|
||||||
**Current:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"amount": 1000,
|
|
||||||
"currency": "RUB",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"itemID": 123,
|
|
||||||
"price": 500,
|
|
||||||
"name": "Product Name"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**NEW:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"amount": 1000,
|
|
||||||
"currency": "RUB",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"itemID": 123,
|
|
||||||
"price": 500,
|
|
||||||
"name": "Product Name",
|
|
||||||
"quantity": 2 // <-- ADD THIS FIELD
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Email Purchase Confirmation
|
|
||||||
|
|
||||||
**POST /purchase-email**
|
|
||||||
|
|
||||||
Update items to include quantity:
|
|
||||||
|
|
||||||
**NEW:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"email": "user@example.com",
|
|
||||||
"telegramUserId": "123456",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"itemID": 123,
|
|
||||||
"name": "Product Name",
|
|
||||||
"price": 500,
|
|
||||||
"currency": "RUB",
|
|
||||||
"quantity": 2 // <-- ADD THIS FIELD
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Future: Filters & Sorting (To Be Discussed)
|
|
||||||
|
|
||||||
### GET /category/{categoryID}
|
|
||||||
|
|
||||||
Add query parameters for filtering and sorting:
|
|
||||||
|
|
||||||
**Proposed Query Parameters:**
|
|
||||||
- `sort`: Sort order (e.g., `price_asc`, `price_desc`, `rating_desc`, `name_asc`)
|
|
||||||
- `minPrice`: Minimum price filter
|
|
||||||
- `maxPrice`: Maximum price filter
|
|
||||||
- `minRating`: Minimum rating filter (1-5)
|
|
||||||
- `count`: Number of items per page (already exists)
|
|
||||||
- `skip`: Offset for pagination (already exists)
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```
|
|
||||||
GET /category/5?sort=price_asc&minPrice=100&maxPrice=500&minRating=4&count=20&skip=0
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** Same as current (array of items)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Summary
|
## Base URLs
|
||||||
|
|
||||||
**Required NOW:**
|
| Brand | Dev | Production |
|
||||||
1. Add `quantity` field to cart item responses
|
|--------|----------------------------------|----------------------------------|
|
||||||
2. Support `quantity` parameter in POST /cart
|
| Dexar | `https://api.dexarmarket.ru:445` | `https://api.dexarmarket.ru:445` |
|
||||||
3. Create new PATCH /cart endpoint for updating quantities
|
| Novo | `https://api.novo.market:444` | `https://api.novo.market:444` |
|
||||||
4. Include `quantity` in payment and email endpoints
|
|
||||||
|
|
||||||
**Future (After Discussion):**
|
---
|
||||||
- Sorting and filtering query parameters for category items endpoint
|
|
||||||
|
## 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
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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 object:**
|
||||||
|
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Authentication (Telegram Login)
|
||||||
|
|
||||||
|
Authentication is **Telegram-based** with **cookie sessions** (HttpOnly, Secure, SameSite=None).
|
||||||
|
|
||||||
|
All auth endpoints must include `withCredentials: true` CORS support.
|
||||||
|
|
||||||
|
### Auth flow
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /auth/session` — Check current session
|
||||||
|
|
||||||
|
**Request:** Cookies only (session cookie set by backend).
|
||||||
|
|
||||||
|
**Response `200`** (authenticated):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "sess_abc123",
|
||||||
|
"telegramUserId": 123456789,
|
||||||
|
"username": "john_doe",
|
||||||
|
"displayName": "John Doe",
|
||||||
|
"active": true,
|
||||||
|
"expiresAt": "2026-03-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response `200`** (expired):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "sess_abc123",
|
||||||
|
"telegramUserId": 123456789,
|
||||||
|
"username": "john_doe",
|
||||||
|
"displayName": "John Doe",
|
||||||
|
"active": false,
|
||||||
|
"expiresAt": "2026-02-27T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response `401`** (no session):
|
||||||
|
```json
|
||||||
|
{ "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 + last) |
|
||||||
|
| `active` | boolean | yes | Whether session is valid |
|
||||||
|
| `expiresAt` | string | yes | ISO 8601 expiration datetime |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /auth/telegram/callback` — Telegram bot auth callback
|
||||||
|
|
||||||
|
Called by the Telegram bot after user authenticates.
|
||||||
|
|
||||||
|
**Request body (from bot):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123456789,
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"username": "john_doe",
|
||||||
|
"photo_url": "https://t.me/i/userpic/...",
|
||||||
|
"auth_date": 1709100000,
|
||||||
|
"hash": "abc123def456..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Must set a session cookie and return:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "sess_abc123",
|
||||||
|
"message": "Authenticated successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cookie requirements:**
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /auth/logout` — End session
|
||||||
|
|
||||||
|
**Request:** Cookies only, empty body `{}`
|
||||||
|
|
||||||
|
**Response `200`:**
|
||||||
|
```json
|
||||||
|
{ "message": "Logged out" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Must clear/invalidate the session cookie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Session refresh
|
||||||
|
|
||||||
|
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 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
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important:** `Access-Control-Allow-Origin` cannot be `*` when `Allow-Credentials: true`. Must be the exact frontend origin.
|
||||||
|
|
||||||
|
**Allowed origins:**
|
||||||
|
- `https://dexarmarket.ru`
|
||||||
|
- `https://novo.market`
|
||||||
|
- `http://localhost:4200` (dev)
|
||||||
|
- `http://localhost:4201` (dev, Novo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Telegram Bot Setup
|
||||||
|
|
||||||
|
Each brand needs its own bot:
|
||||||
|
- **Dexar:** `@dexarmarket_bot`
|
||||||
|
- **Novo:** `@novomarket_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
|
||||||
|
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 сессии (если есть) для привязки к заказу, но не требуют авторизации строго. Фронтенд проверяет авторизацию перед оформлением заказа.
|
||||||
@@ -1,500 +1,11 @@
|
|||||||
we ae going to redesing dexar. here are css from the figma. i will try to explain all.
|
bro we need to do changes, that client required
|
||||||
pls do responsive and better! thank you
|
1. we need to add location logic
|
||||||
|
1.1 the catalogs will come or for global or for exact region
|
||||||
you are free to do changes better and responsive ofc!!
|
1.2 need to add a place where the user can choose his region like city if choosed moscow the country is set russian
|
||||||
|
1.3 can we try to understand what country is user logged or whach city by global ip and set it?
|
||||||
Header:
|
2. we need to add somekind of user login logic
|
||||||
<div class="frame">
|
2.1 user can add to cart, look the items and etc without logged in, but when he is going to buy/pay ->
|
||||||
<img class="group" src="img/group-2.png" />
|
at first he have to login with telegram, i will send you the bots adress.
|
||||||
<div class="div">
|
2.1.1 if is not logged -> will see the QR or link for logging via telegram
|
||||||
<div class="div-wrapper"><div class="text-wrapper">Главная</div></div>
|
2.1.2 if logged we need to ping server to check if he is active user. the expiration date (like day or 5 days) we will get from bakcend with session id
|
||||||
<div class="div-wrapper-2"><div class="text-wrapper">О нас</div></div>
|
2.2 and when user is logged, that time he can do a payment
|
||||||
<div class="div-wrapper-3"><div class="text-wrapper-2">Контакты</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="frame-wrapper">
|
|
||||||
<div class="div-2">
|
|
||||||
<div class="text-wrapper-3">Искать...</div>
|
|
||||||
<img class="icn" src="img/icn-05.png" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="korzina-frame"><img class="cart" src="img/cart.svg" /></div>
|
|
||||||
<div class="RU-frame">
|
|
||||||
<div class="text-wrapper-4">RU</div>
|
|
||||||
<div class="group-2"><img class="line" src="img/line-2.svg" /> <img class="img" src="img/line-3.svg" /></div>
|
|
||||||
</div>
|
|
||||||
<div class="login-frame"><img class="icon" src="img/icon.svg" /></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
.frame {
|
|
||||||
width: 1440px;
|
|
||||||
height: 84px;
|
|
||||||
display: flex;
|
|
||||||
background-color: #74787b1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .group {
|
|
||||||
margin-top: 18px;
|
|
||||||
width: 148px;
|
|
||||||
height: 48px;
|
|
||||||
position: relative;
|
|
||||||
margin-left: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .div {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-top: 18px;
|
|
||||||
width: 569px;
|
|
||||||
height: 49px;
|
|
||||||
position: relative;
|
|
||||||
margin-left: 57px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .div-wrapper {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 48px;
|
|
||||||
position: relative;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background-color: #497671;
|
|
||||||
border-radius: 13px 0px 0px 13px;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #d3dad9;
|
|
||||||
box-shadow: 0px 3px 4px #00000026;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .text-wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: fit-content;
|
|
||||||
margin-top: -1.00px;
|
|
||||||
font-family: "DM Sans-SemiBold", Helvetica;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 22px;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .div-wrapper-2 {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 63px;
|
|
||||||
position: relative;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background-color: #a1b4b5;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #d3dad9;
|
|
||||||
box-shadow: 0px 3px 4px #00000026;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .div-wrapper-3 {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 42px;
|
|
||||||
position: relative;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background-color: #ffffffbd;
|
|
||||||
border-radius: 0px 13px 13px 0px;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #d3dad9;
|
|
||||||
box-shadow: 0px 3px 4px #00000026;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .text-wrapper-2 {
|
|
||||||
color: #1e3c38;
|
|
||||||
position: relative;
|
|
||||||
width: fit-content;
|
|
||||||
margin-top: -1.00px;
|
|
||||||
font-family: "DM Sans-SemiBold", Helvetica;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 22px;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .frame-wrapper {
|
|
||||||
margin-top: 18px;
|
|
||||||
width: 234px;
|
|
||||||
height: 49px;
|
|
||||||
position: relative;
|
|
||||||
margin-left: 126px;
|
|
||||||
background-color: #ffffffbd;
|
|
||||||
border-radius: 22px;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #d2dad9;
|
|
||||||
box-shadow: 0px 3px 4px #00000026;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .div-2 {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 27px;
|
|
||||||
padding: 0px 20px;
|
|
||||||
position: relative;
|
|
||||||
top: 10px;
|
|
||||||
left: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .text-wrapper-3 {
|
|
||||||
color: #828e8d;
|
|
||||||
position: relative;
|
|
||||||
width: fit-content;
|
|
||||||
margin-top: -1.00px;
|
|
||||||
font-family: "DM Sans-SemiBold", Helvetica;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 22px;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .icn {
|
|
||||||
position: absolute;
|
|
||||||
top: 1px;
|
|
||||||
left: -32px;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .korzina-frame {
|
|
||||||
margin-top: 26px;
|
|
||||||
width: 48px;
|
|
||||||
height: 32px;
|
|
||||||
position: relative;
|
|
||||||
margin-left: 57px;
|
|
||||||
background-color: #ffffff4c;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #667a77;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .cart {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(50.00% - 13px);
|
|
||||||
left: calc(50.00% - 14px);
|
|
||||||
width: 27px;
|
|
||||||
height: 27px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .RU-frame {
|
|
||||||
display: flex;
|
|
||||||
margin-top: 26px;
|
|
||||||
width: 67px;
|
|
||||||
height: 32px;
|
|
||||||
position: relative;
|
|
||||||
margin-left: 4px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px;
|
|
||||||
background-color: #ffffff4c;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #667a77;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .text-wrapper-4 {
|
|
||||||
position: relative;
|
|
||||||
width: fit-content;
|
|
||||||
margin-top: -6.50px;
|
|
||||||
margin-bottom: -4.50px;
|
|
||||||
font-family: "DM Sans-Medium", Helvetica;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1e3c38;
|
|
||||||
font-size: 24px;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .group-2 {
|
|
||||||
position: relative;
|
|
||||||
width: 9.29px;
|
|
||||||
height: 14px;
|
|
||||||
transform: rotate(90.00deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .line {
|
|
||||||
top: -2px;
|
|
||||||
position: absolute;
|
|
||||||
left: 1px;
|
|
||||||
width: 9px;
|
|
||||||
height: 10px;
|
|
||||||
transform: rotate(-90.00deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .img {
|
|
||||||
top: 6px;
|
|
||||||
position: absolute;
|
|
||||||
left: 1px;
|
|
||||||
width: 9px;
|
|
||||||
height: 10px;
|
|
||||||
transform: rotate(-90.00deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .login-frame {
|
|
||||||
margin-top: 26px;
|
|
||||||
width: 48px;
|
|
||||||
height: 32px;
|
|
||||||
position: relative;
|
|
||||||
margin-left: 4px;
|
|
||||||
background-color: #ffffff4c;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #667a77;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .icon {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(50.00% - 12px);
|
|
||||||
left: calc(50.00% - 12px);
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1. background: rgba(117, 121, 124, 0.1);
|
|
||||||
padding: 14px 0px;
|
|
||||||
width: 1440px;
|
|
||||||
height: 84px;
|
|
||||||
2. logo stays the
|
|
||||||
|
|
||||||
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="_Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 308.43 100.53">
|
|
||||||
<defs>
|
|
||||||
<style>
|
|
||||||
.cls-1 {
|
|
||||||
fill: #477470;
|
|
||||||
stroke-width: 0px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<path class="cls-1" d="m101.66,15.71c-4.16-.3-8.34-.35-12.51-.46-3.85-.1-7.69-.15-11.54-.21-9.14-.15-18.29-.32-27.44-.44-7.84-.11-15.68-.18-23.53-.21-.83,0-1.17-.3-1.33-1.01-.81-3.51-1.64-7.02-2.44-10.53-.31-1.33-1.42-2.36-2.68-2.41-1.59-.07-3.18-.17-4.77-.21C11.37.13,7.31.06,3.25,0,1.27-.03,0,1.13,0,2.92c0,1.78,1.38,3.14,3.26,3.17,4.28.08,8.56.17,12.84.2.89,0,1.34.26,1.56,1.17,1.2,4.99,2.47,9.95,3.69,14.93,2.3,9.38,4.58,18.77,6.88,28.15,1.11,4.54,2.21,9.07,3.36,13.6.28,1.11.15,1.73-1.02,2.31-3.76,1.85-5.33,5.91-4.45,9.93.91,4.11,4.58,6.95,9.07,7.02.46,0,.92,0,1.38,0-2.97,1.75-4.68,4.13-4.95,7.42-.27,3.32,1.42,5.8,3.95,7.96-4.85.74-6.27.75-9.41,1.23.8.23,1.31.11,1.98.12,4.46.05,8.92.17,13.37.01,4.94-.17,8.86-5.16,7.57-10.63-.63-2.66-2.21-4.7-5.04-5.9h39.73c-2.87,1.74-4.53,4.14-4.85,7.36-.32,3.29,1.08,5.9,3.89,8.11-9.01.38-17.71.47-26.34,1.09l30.02.35c1.84-.07,3.73.03,5.49-.97,4.82-2.75,6.23-8.3,3.26-12.73-.84-1.26-2.17-2.19-3.21-3.2,1.3,0,2.83.03,4.35,0,1.66-.04,2.81-1.34,2.78-3.08-.02-1.56-1.25-2.77-2.82-2.79-6.68-.07-13.36-.18-20.04-.2-9.37-.04-18.74-.01-28.11-.02-4.25,0-8.5,0-12.75,0-2.17,0-3.72-1.47-3.62-3.37.09-1.79,1.73-3.16,3.83-3.15,8.39.04,16.77.1,25.16.13,8.61.04,17.21.06,25.82.07.97,0,1.94-.09,2.9-.21,3.83-.52,6.67-3.16,7.69-6.89,1.84-6.75,3.76-13.47,5.65-20.21,1.36-4.84,2.79-9.66,4.08-14.52.59-2.2,1.13-4.45,1.32-6.7.29-3.53-2.89-6.7-6.6-6.96Zm-13.8,71.86c2.2-.07,4.11,1.95,4.1,4.15-.18,2.67-1.84,3.97-4.24,4.07-2.17.08-4.06-1.98-4.03-4.18.03-2.3,1.72-3.96,4.17-4.04Zm-47.43-.03c2.45-.06,4.19,1.8,4.15,4.03-.05,2.63-2.02,3.98-4.06,4.02-2.23.04-4.05-1.86-4.15-4.07-.1-2.22,2.05-4.07,4.06-3.98Zm30.45-67.01v12.33c-1.89,0-3.69.02-5.48,0-3.15-.05-6.3-.18-9.45-.18-.98,0-1.2-.35-1.27-1.24-.22-2.76-.55-5.5-.82-8.25-.09-.93-.15-1.86-.21-2.66h17.23Zm-.14,17.64v12.64c-4.47,0-8.88.02-13.29-.04-.26,0-.71-.63-.75-1.01-.35-3.18-.62-6.37-.91-9.55,0-.04,0-.07,0-.11-.15-1.98-.15-1.95,1.83-1.94,4.35.02,8.69,0,13.13,0Zm-41.31-8.1c-.62-2.71-1.26-5.41-1.88-8.12-.15-.65-.27-1.32-.43-2.1,7.05.12,13.97.24,21.04.37.41,4.15.81,8.23,1.19,12.14-5.73,0-11.3,0-16.87,0-.11,0-.22-.02-.32-.03-2.25-.14-2.24-.14-2.73-2.26Zm5.02,20.67c-1.01-4.24-2.02-8.49-3.03-12.7h18.64c.47,4.3.93,8.46,1.39,12.7h-17.01Zm57.74,8.57c-.3,1.1-.54,2.23-.89,3.31-.51,1.58-1.87,2.54-3.47,2.54-16.08-.01-32.17-.04-48.25,0-1.26,0-1.71-.36-1.95-1.57-.44-2.27-1.1-4.5-1.65-6.75-.04-.17,0-.35,0-.67,18.95.13,37.85.26,56.99.39-.29,1.03-.53,1.89-.77,2.76Zm4.75-16.54c-.7,2.51-1.41,5.02-2.17,7.51-.09.29-.56.65-.85.65-5.59.04-11.18.04-16.77,0-.29,0-.83-.42-.84-.64-.05-3.87-.04-7.75-.04-11.6h21.71c-.38,1.5-.69,2.8-1.05,4.08Zm5.38-19.31c-.83,2.95-1.7,5.89-2.49,8.85-.19.73-.47,1.01-1.23.99-6.45-.16-12.91-.28-19.36-.41-.94-.02-1.88,0-2.97,0,0-3.91.01-7.67,0-11.43,0-.76.45-.78,1-.77,2.83.08,5.65.17,8.48.22,4.93.09,9.86.15,14.79.22,1.49.02,2.18.94,1.78,2.34Z"/>
|
|
||||||
<path class="cls-1" d="m299.48,39.67c.17-.09.36-.18.54-.28,3.09-1.58,5.27-3.86,5.99-7.4.42-2.08.51-4.14.17-6.22-.51-3.09-1.95-5.6-4.74-7.19-2.92-1.67-6.16-2.13-9.43-2.22-4.54-.13-9.08-.02-13.62-.04-.68,0-.98.18-.98.92.02,11.58.02,23.15,0,34.73,0,.72.26.96.96.95,1.71-.03,3.41-.03,5.12.02.85.03,1.15-.26,1.14-1.12-.04-3.23-.02-6.46-.02-9.69v-1.18c2.28,0,4.38.04,6.48-.02.77-.02,1.18.27,1.57.87,1.95,3.04,4,6.02,5.85,9.11.89,1.49,1.85,2.24,3.68,2.06,1.95-.2,3.94-.04,6.23-.04-3.09-4.57-6.01-8.89-8.95-13.25Zm-.65-8.49c-.41,1.92-1.85,2.99-3.63,3.16-3.3.31-6.64.33-9.96.42-.2,0-.59-.48-.59-.74-.04-3.81-.03-7.61-.03-11.8,3.68.22,7.25.24,10.77.71,2.49.33,3.8,2.22,3.81,4.75,0,1.17-.13,2.36-.37,3.51Z"/>
|
|
||||||
<path class="cls-1" d="m160.88,43.32c2.31-4.64,2.45-9.55,1.34-14.5-.78-3.47-2.57-6.41-5.35-8.65-3.79-3.05-8.3-4.12-13.04-4.26-3.99-.11-7.99.01-11.98-.05-1.08-.02-1.33.33-1.33,1.36.03,11.35.02,22.71.02,34.06v1.2c3.27,0,6.38.06,9.5-.02,2.92-.07,5.87-.03,8.73-.48,5.42-.85,9.62-3.66,12.11-8.67Zm-5.96-4c-1.11,3.56-4.21,6.16-7.89,6.59-2.68.32-5.41.24-8.12.41-.96.06-1.17-.33-1.16-1.19.03-3.66.01-7.32.01-10.99.02,0,.03,0,.05,0,0-3.7-.01-7.4.02-11.09,0-.28.34-.81.52-.81,3.16.01,6.35-.32,9.47.56,4.39,1.24,6.86,4.16,7.57,8.62.43,2.66.34,5.3-.47,7.88Z"/>
|
|
||||||
<path class="cls-1" d="m176.08,37.91c0-.65.38-.66.86-.65,3.92.06,7.84.12,11.76.16,1.36.02,2.72,0,4.17,0,0-1.95-.04-3.62.02-5.28.03-.84-.28-1.03-1.07-1.02-4.83.03-9.66.02-14.49.02h-1.27c0-2.91-.01-5.7.03-8.48,0-.17.43-.48.66-.48,5.15-.02,10.31-.01,15.46-.01.47,0,.94-.05,1.42-.03.73.04,1.03-.22,1-1-.06-1.27-.07-2.54,0-3.81.06-.94-.22-1.25-1.2-1.24-7.04.03-14.09,0-21.13,0-1.11,0-2.22,0-3.31,0v36.58h25.96v-6.21h-18.86c0-2.98,0-5.76,0-8.55Z"/>
|
|
||||||
<path class="cls-1" d="m265.06,35c-2.49-6.04-4.99-12.08-7.52-18.1-.12-.28-.65-.53-1-.54-1.92-.05-3.85,0-5.77-.04-.7-.02-1,.27-1.26.89-2.73,6.57-5.49,13.12-8.23,19.68-2.17,5.21-4.32,10.42-6.61,15.95,2.43,0,4.65.03,6.86-.04.34-.01.81-.44.96-.79.93-2.17,1.76-4.38,2.69-6.55.15-.34.61-.79.93-.79,4.94.01,9.87.11,14.81.13.67,0,.84.31,1.04.81.86,2.16,1.73,4.31,2.63,6.45.11.26.38.65.59.65,2.34.05,4.68.03,7.12.03-.11-.33-.19-.63-.31-.91-2.3-5.62-4.6-11.23-6.91-16.84Zm-17.29,3.48c1.91-4.7,3.81-9.35,5.79-14.21,1.96,4.85,3.84,9.48,5.76,14.21h-11.54Z"/>
|
|
||||||
<path class="cls-1" d="m225.35,52.65c2.59.09,5.19.05,7.88.05-.08-.32-.09-.51-.18-.64-1.34-1.94-2.7-3.86-4.04-5.8-2.54-3.68-5.05-7.38-7.59-11.06-.54-.78-.8-1.41-.12-2.37,2.6-3.69,5.06-7.47,7.59-11.21,1.18-1.74,2.4-3.46,3.72-5.35-.47-.07-.71-.13-.95-.13-2.11,0-4.21-.06-6.32.03-.52.02-1.21.36-1.51.77-1.3,1.77-2.49,3.62-3.72,5.43-1.3,1.92-2.61,3.85-3.96,5.84-.26-.31-.43-.49-.57-.7-2.13-3.22-4.31-6.4-6.36-9.67-.79-1.26-1.63-1.88-3.2-1.76-2.04.17-4.09.04-6.28.04.14.36.18.57.29.73,3.71,5.4,7.42,10.8,11.15,16.19.43.62.42,1.09-.02,1.72-3.29,4.7-6.54,9.42-9.8,14.14-.83,1.21-1.63,2.45-2.53,3.81,2.74,0,5.24.02,7.74-.02.31,0,.73-.26.92-.53,2.4-3.49,4.77-7,7.15-10.51.45-.67.9-1.34,1.38-2.05,2.79,4.08,5.5,8.05,8.23,12,.29.42.72,1.05,1.1,1.06Z"/>
|
|
||||||
<path class="cls-1" d="m141.52,77.32l-1.21,2.83h-.11l-1.21-2.83-3.33-7.36h-3.58v14.94h2.99v-6.83c0-1.39-.25-3.38-.4-4.75h.11l1.47,3.4,3.19,6.78h1.5l3.19-6.78,1.5-3.4h.11c-.17,1.37-.42,3.36-.42,4.75v6.83h3.08v-14.94h-3.61l-3.24,7.36Z"/>
|
|
||||||
<path class="cls-1" d="m162.26,69.96l-6.04,14.94h3.36l1.44-4.04h6.18l1.44,4.04h3.47l-6.01-14.94h-3.84Zm-.51,8.82l.65-1.83c.59-1.58,1.13-3.27,1.64-4.93h.11c.54,1.64,1.1,3.36,1.66,4.93l.65,1.83h-4.71Z"/>
|
|
||||||
<path class="cls-1" d="m192.96,74.39c0-3.34-2.96-4.43-6.8-4.43h-6.21v14.94h3.27v-5.85h2.79l3.98,5.85h3.67l-4.4-6.24c2.23-.62,3.7-1.99,3.7-4.27Zm-7.14,2.56h-2.6v-4.87h2.6c2.54,0,3.89.59,3.89,2.31s-1.35,2.56-3.89,2.56Z"/>
|
|
||||||
<polygon class="cls-1" points="215.96 69.96 212.34 69.96 205.77 76.75 205.69 76.75 205.69 69.96 202.41 69.96 202.41 84.9 205.69 84.9 205.69 80.54 208.34 77.87 213.3 84.9 216.92 84.9 210.29 75.79 215.96 69.96"/>
|
|
||||||
<polygon class="cls-1" points="228.09 78.25 234.72 78.25 234.72 76.01 228.09 76.01 228.09 72.2 235.9 72.2 235.9 69.96 224.82 69.96 224.82 84.9 236.19 84.9 236.19 82.66 228.09 82.66 228.09 78.25"/>
|
|
||||||
<polygon class="cls-1" points="243.92 72.2 249.25 72.2 249.25 84.9 252.52 84.9 252.52 72.2 257.83 72.2 257.83 69.96 243.92 69.96 243.92 72.2"/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
|
|
||||||
3. after logo 3 btns in same div and without gap
|
|
||||||
3.1 "главная"
|
|
||||||
border: 1px solid #d3dad9;
|
|
||||||
border-radius: 13px 0 0 13px;
|
|
||||||
padding: 10px 48px;
|
|
||||||
width: 187px;
|
|
||||||
height: 49px;
|
|
||||||
3.2 "о нас"border:
|
|
||||||
1px solid #d3dad9;
|
|
||||||
padding: 10px 63px;
|
|
||||||
width: 188px;
|
|
||||||
height: 49px;
|
|
||||||
3.3 "котакты"border:
|
|
||||||
1px solid #d3dad9;
|
|
||||||
border-radius: 0 13px 13px 0;
|
|
||||||
padding: 10px 42px;
|
|
||||||
width: 194px;
|
|
||||||
height: 49px;
|
|
||||||
|
|
||||||
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
|
|
||||||
background: rgba(255, 255, 255, 0.74);
|
|
||||||
hover: background: #a1b4b5;
|
|
||||||
active : background: #497671;
|
|
||||||
|
|
||||||
|
|
||||||
4. next search btn with place holder "искать..." and on the left fixed svg icon "<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12Z" fill="#576463" />
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.2929 18.2929C18.6834 17.9024 19.3166 17.9024 19.7071 18.2929L25.7071 24.2929C26.0976 24.6834 26.0976 25.3166 25.7071 25.7071C25.3166 26.0976 24.6834 26.0976 24.2929 25.7071L18.2929 19.7071C17.9024 19.3166 17.9024 18.6834 18.2929 18.2929Z" fill="#576463" />
|
|
||||||
</svg>"
|
|
||||||
border: 1px solid #d3dad9;
|
|
||||||
border-radius: 22px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
width: 234px;
|
|
||||||
height: 49px;
|
|
||||||
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
|
|
||||||
background: rgba(255, 255, 255, 0.74);
|
|
||||||
|
|
||||||
|
|
||||||
5. after 3 buttons to the right
|
|
||||||
5.1 cart btn
|
|
||||||
border-radius: 12px;
|
|
||||||
fill: rgba(255, 255, 255, 0.3);
|
|
||||||
border: 1px solid #677b78;
|
|
||||||
|
|
||||||
<svg width="48" height="32" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" fill="white" fill-opacity="0.3" />
|
|
||||||
<path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" stroke="#677B78" />
|
|
||||||
<path d="M10 3.9C10 3.40294 10.4029 3 10.9 3H13.6C14.013 3 14.373 3.28107 14.4731 3.68172L15.2027 6.6H36.1C36.3677 6.6 36.6216 6.7192 36.7925 6.92523C36.9635 7.13125 37.0339 7.40271 36.9846 7.66586L34.2846 22.0659C34.2048 22.4915 33.8331 22.8 33.4 22.8H31.6H19H17.2C16.7669 22.8 16.3952 22.4915 16.3154 22.0659L13.6204 7.69224L12.8973 4.8H10.9C10.4029 4.8 10 4.39706 10 3.9ZM15.5844 8.4L17.9469 21H32.6531L35.0156 8.4H15.5844ZM19 22.8C17.0118 22.8 15.4 24.4118 15.4 26.4C15.4 28.3882 17.0118 30 19 30C20.9882 30 22.6 28.3882 22.6 26.4C22.6 24.4118 20.9882 22.8 19 22.8ZM31.6 22.8C29.6118 22.8 28 24.4118 28 26.4C28 28.3882 29.6118 30 31.6 30C33.5882 30 35.2 28.3882 35.2 26.4C35.2 24.4118 33.5882 22.8 31.6 22.8ZM19 24.6C19.9941 24.6 20.8 25.4059 20.8 26.4C20.8 27.3941 19.9941 28.2 19 28.2C18.0059 28.2 17.2 27.3941 17.2 26.4C17.2 25.4059 18.0059 24.6 19 24.6ZM31.6 24.6C32.5941 24.6 33.4 25.4059 33.4 26.4C33.4 27.3941 32.5941 28.2 31.6 28.2C30.6059 28.2 29.8 27.3941 29.8 26.4C29.8 25.4059 30.6059 24.6 31.6 24.6Z" fill="#1E3C38" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
5.2 lang selector btn style border: 1px solid #677b78;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 6px;
|
|
||||||
width: 67px;
|
|
||||||
height: 32px;
|
|
||||||
|
|
||||||
|
|
||||||
HERO
|
|
||||||
we are goung to have a width wide hero, photos for dekstop and mobile you can see in the same folder
|
|
||||||
|
|
||||||
on it text. here are codes from figma
|
|
||||||
<div class="frame">
|
|
||||||
<div class="text-wrapper">Здесь ты найдёшь всё</div>
|
|
||||||
<p class="div">Тысячи товаров в одном месте</p>
|
|
||||||
<div class="text-wrapper-2">просто и удобно</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
.frame {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 639px;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 18px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .text-wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 659px;
|
|
||||||
margin-top: -1.00px;
|
|
||||||
margin-right: -20.00px;
|
|
||||||
font-size: 57px;
|
|
||||||
font-family: "DM Sans-Medium", Helvetica;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1e3c38;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .div {
|
|
||||||
position: absolute;
|
|
||||||
top: 87px;
|
|
||||||
left: 0;
|
|
||||||
width: 581px;
|
|
||||||
font-size: 34px;
|
|
||||||
font-family: "DM Sans-Medium", Helvetica;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1e3c38;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .text-wrapper-2 {
|
|
||||||
position: absolute;
|
|
||||||
top: 133px;
|
|
||||||
left: 0;
|
|
||||||
width: 281px;
|
|
||||||
font-size: 34px;
|
|
||||||
font-family: "DM Sans-Medium", Helvetica;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1e3c38;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
under the text we have btns.. hovers and actives for all web site are the same as from header
|
|
||||||
|
|
||||||
first
|
|
||||||
<div class="pereyti-v-katalog"><div class="text-wrapper">Перейти в каталог</div></div>
|
|
||||||
.pereyti-v-katalog {
|
|
||||||
width: 337px;
|
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
|
||||||
border-radius: 13px;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #d3dad9;
|
|
||||||
background: linear-gradient(
|
|
||||||
360deg,
|
|
||||||
rgba(73, 118, 113, 1) 0%,
|
|
||||||
rgba(167, 206, 202, 1) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pereyti-v-katalog .text-wrapper {
|
|
||||||
margin-top: 12px;
|
|
||||||
width: 269px;
|
|
||||||
height: 36px;
|
|
||||||
margin-left: 34px;
|
|
||||||
position: relative;
|
|
||||||
font-family: "DM Sans-Medium", Helvetica;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 27px;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 1.08px;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
second btn
|
|
||||||
<div class="frame">
|
|
||||||
<div class="text-wrapper">Найти товар</div>
|
|
||||||
<div class="group"><img class="line" src="img/line-2.svg" /> <img class="img" src="img/line-3.svg" /></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
.frame {
|
|
||||||
width: 264px;
|
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
|
||||||
gap: 9.2px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
border-radius: 13px;
|
|
||||||
border: 1px solid;
|
|
||||||
border-color: #d3dad9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .text-wrapper {
|
|
||||||
margin-top: 12px;
|
|
||||||
width: 181px;
|
|
||||||
height: 36px;
|
|
||||||
position: relative;
|
|
||||||
margin-left: 36px;
|
|
||||||
font-family: "DM Sans-Medium", Helvetica;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1e3c38;
|
|
||||||
font-size: 27px;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 1.08px;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .group {
|
|
||||||
margin-top: 22.0px;
|
|
||||||
width: 10.62px;
|
|
||||||
height: 16px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .line {
|
|
||||||
top: -1px;
|
|
||||||
width: 12px;
|
|
||||||
position: absolute;
|
|
||||||
left: 1px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame .img {
|
|
||||||
top: 7px;
|
|
||||||
width: 11px;
|
|
||||||
position: absolute;
|
|
||||||
left: 1px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|||||||
72
package-lock.json
generated
72
package-lock.json
generated
@@ -8,11 +8,15 @@
|
|||||||
"name": "dexarmarket",
|
"name": "dexarmarket",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@angular/animations": "^21.1.5",
|
||||||
|
"@angular/cdk": "^21.1.5",
|
||||||
"@angular/common": "^21.0.6",
|
"@angular/common": "^21.0.6",
|
||||||
"@angular/compiler": "^21.0.6",
|
"@angular/compiler": "^21.0.6",
|
||||||
"@angular/core": "^21.0.6",
|
"@angular/core": "^21.0.6",
|
||||||
"@angular/forms": "^21.0.6",
|
"@angular/forms": "^21.0.6",
|
||||||
|
"@angular/material": "^21.1.5",
|
||||||
"@angular/platform-browser": "^21.0.6",
|
"@angular/platform-browser": "^21.0.6",
|
||||||
|
"@angular/platform-browser-dynamic": "^21.1.5",
|
||||||
"@angular/router": "^21.0.6",
|
"@angular/router": "^21.0.6",
|
||||||
"@angular/service-worker": "^21.0.6",
|
"@angular/service-worker": "^21.0.6",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
@@ -324,6 +328,21 @@
|
|||||||
"yarn": ">= 1.13.0"
|
"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": {
|
"node_modules/@angular/build": {
|
||||||
"version": "21.1.0",
|
"version": "21.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.0.tgz",
|
||||||
@@ -472,6 +491,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@angular/cli": {
|
||||||
"version": "21.1.0",
|
"version": "21.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.0.tgz",
|
||||||
@@ -613,6 +648,23 @@
|
|||||||
"rxjs": "^6.5.3 || ^7.4.0"
|
"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": {
|
"node_modules/@angular/platform-browser": {
|
||||||
"version": "21.0.6",
|
"version": "21.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.6.tgz",
|
"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": {
|
"node_modules/@angular/router": {
|
||||||
"version": "21.0.6",
|
"version": "21.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.6.tgz",
|
||||||
@@ -7687,7 +7757,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||||
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"entities": "^6.0.0"
|
"entities": "^6.0.0"
|
||||||
@@ -7741,7 +7810,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
|
|||||||
@@ -16,11 +16,15 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@angular/animations": "^21.1.5",
|
||||||
|
"@angular/cdk": "^21.1.5",
|
||||||
"@angular/common": "^21.0.6",
|
"@angular/common": "^21.0.6",
|
||||||
"@angular/compiler": "^21.0.6",
|
"@angular/compiler": "^21.0.6",
|
||||||
"@angular/core": "^21.0.6",
|
"@angular/core": "^21.0.6",
|
||||||
"@angular/forms": "^21.0.6",
|
"@angular/forms": "^21.0.6",
|
||||||
|
"@angular/material": "^21.1.5",
|
||||||
"@angular/platform-browser": "^21.0.6",
|
"@angular/platform-browser": "^21.0.6",
|
||||||
|
"@angular/platform-browser-dynamic": "^21.1.5",
|
||||||
"@angular/router": "^21.0.6",
|
"@angular/router": "^21.0.6",
|
||||||
"@angular/service-worker": "^21.0.6",
|
"@angular/service-worker": "^21.0.6",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { cacheInterceptor } from './interceptors/cache.interceptor';
|
import { cacheInterceptor } from './interceptors/cache.interceptor';
|
||||||
|
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
|
||||||
|
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
|
||||||
import { provideServiceWorker } from '@angular/service-worker';
|
import { provideServiceWorker } from '@angular/service-worker';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
@@ -15,7 +17,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
withInMemoryScrolling({ scrollPositionRestoration: 'top' })
|
withInMemoryScrolling({ scrollPositionRestoration: 'top' })
|
||||||
),
|
),
|
||||||
provideHttpClient(
|
provideHttpClient(
|
||||||
withInterceptors([cacheInterceptor])
|
withInterceptors([mockDataInterceptor, apiHeadersInterceptor, cacheInterceptor])
|
||||||
),
|
),
|
||||||
provideServiceWorker('ngsw-worker.js', {
|
provideServiceWorker('ngsw-worker.js', {
|
||||||
enabled: !isDevMode(),
|
enabled: !isDevMode(),
|
||||||
|
|||||||
@@ -19,4 +19,5 @@
|
|||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
<app-footer></app-footer>
|
<app-footer></app-footer>
|
||||||
|
<app-telegram-login />
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Title } from '@angular/platform-browser';
|
|||||||
import { HeaderComponent } from './components/header/header.component';
|
import { HeaderComponent } from './components/header/header.component';
|
||||||
import { FooterComponent } from './components/footer/footer.component';
|
import { FooterComponent } from './components/footer/footer.component';
|
||||||
import { BackButtonComponent } from './components/back-button/back-button.component';
|
import { BackButtonComponent } from './components/back-button/back-button.component';
|
||||||
|
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
|
||||||
import { ApiService } from './services';
|
import { ApiService } from './services';
|
||||||
import { interval, concat } from 'rxjs';
|
import { interval, concat } from 'rxjs';
|
||||||
import { filter, first } from 'rxjs/operators';
|
import { filter, first } from 'rxjs/operators';
|
||||||
@@ -16,7 +17,7 @@ import { TranslateService } from './i18n/translate.service';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe],
|
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TelegramLoginComponent, TranslatePipe],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="novo-right">
|
<div class="novo-right">
|
||||||
|
<app-region-selector />
|
||||||
<app-language-selector />
|
<app-language-selector />
|
||||||
|
|
||||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()">
|
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()">
|
||||||
@@ -106,6 +107,11 @@
|
|||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Region Selector (desktop only) -->
|
||||||
|
<div class="dexar-region-selector dexar-lang-desktop">
|
||||||
|
<app-region-selector />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Language Selector (desktop only) -->
|
<!-- Language Selector (desktop only) -->
|
||||||
<div class="dexar-lang-selector dexar-lang-desktop">
|
<div class="dexar-lang-selector dexar-lang-desktop">
|
||||||
<app-language-selector />
|
<app-language-selector />
|
||||||
@@ -171,6 +177,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Region Selector in mobile menu -->
|
||||||
|
<div class="dexar-mobile-lang">
|
||||||
|
<app-region-selector />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Language Selector in mobile menu -->
|
<!-- Language Selector in mobile menu -->
|
||||||
<div class="dexar-mobile-lang">
|
<div class="dexar-mobile-lang">
|
||||||
<app-language-selector />
|
<app-language-selector />
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { CartService, LanguageService } from '../../services';
|
|||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { LogoComponent } from '../logo/logo.component';
|
import { LogoComponent } from '../logo/logo.component';
|
||||||
import { LanguageSelectorComponent } from '../language-selector/language-selector.component';
|
import { LanguageSelectorComponent } from '../language-selector/language-selector.component';
|
||||||
|
import { RegionSelectorComponent } from '../region-selector/region-selector.component';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, LangRoutePipe, TranslatePipe],
|
imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, RegionSelectorComponent, LangRoutePipe, TranslatePipe],
|
||||||
templateUrl: './header.component.html',
|
templateUrl: './header.component.html',
|
||||||
styleUrls: ['./header.component.scss'],
|
styleUrls: ['./header.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
|||||||
@@ -18,14 +18,21 @@
|
|||||||
<div class="item-card">
|
<div class="item-card">
|
||||||
<a [routerLink]="['/item', product.itemID] | langRoute" class="item-link">
|
<a [routerLink]="['/item', product.itemID] | langRoute" class="item-link">
|
||||||
<div class="item-image">
|
<div class="item-image">
|
||||||
<img [src]="getItemImage(product)" [alt]="product.name" loading="lazy" />
|
<img [src]="getItemImage(product)" [alt]="itemName(product)" loading="lazy" />
|
||||||
@if (product.discount > 0) {
|
@if (product.discount > 0) {
|
||||||
<span class="discount-badge">-{{ product.discount }}%</span>
|
<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>
|
||||||
|
|
||||||
<div class="item-details">
|
<div class="item-details">
|
||||||
<h3 class="item-name">{{ product.name }}</h3>
|
<h3 class="item-name">{{ itemName(product) }}</h3>
|
||||||
|
|
||||||
@if (product.rating) {
|
@if (product.rating) {
|
||||||
<div class="item-rating">
|
<div class="item-rating">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, signal, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, OnInit, signal, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||||
import { DecimalPipe } from '@angular/common';
|
import { DecimalPipe } from '@angular/common';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { CarouselModule } from 'primeng/carousel';
|
import { CarouselModule } from 'primeng/carousel';
|
||||||
@@ -7,7 +7,8 @@ import { TagModule } from 'primeng/tag';
|
|||||||
import { ApiService, CartService } from '../../services';
|
import { ApiService, CartService } from '../../services';
|
||||||
import { Item } from '../../models';
|
import { Item } from '../../models';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { getDiscountedPrice, getMainImage } from '../../utils/item.utils';
|
import { getDiscountedPrice, getMainImage, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||||
|
import { LanguageService } from '../../services/language.service';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
|
|
||||||
@@ -98,6 +99,10 @@ export class ItemsCarouselComponent implements OnInit {
|
|||||||
|
|
||||||
readonly getItemImage = getMainImage;
|
readonly getItemImage = getMainImage;
|
||||||
readonly getDiscountedPrice = getDiscountedPrice;
|
readonly getDiscountedPrice = getDiscountedPrice;
|
||||||
|
readonly getBadgeClass = getBadgeClass;
|
||||||
|
|
||||||
|
private langService = inject(LanguageService);
|
||||||
|
itemName(product: Item): string { return getTranslatedField(product, 'name', this.langService.currentLanguage()); }
|
||||||
|
|
||||||
addToCart(event: Event, item: Item): void {
|
addToCart(event: Event, item: Item): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -22,4 +22,25 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button class="currency-button" (click)="toggleCurrency()">
|
||||||
|
<span class="currency-symbol">{{ languageService.getCurrentCurrency()?.symbol }}</span>
|
||||||
|
<span class="currency-code">{{ languageService.currentCurrency() }}</span>
|
||||||
|
<svg class="dropdown-arrow" [class.rotated]="currencyOpen" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="currency-dropdown" [class.open]="currencyOpen">
|
||||||
|
@for (cur of languageService.currencies; track cur.code) {
|
||||||
|
<button
|
||||||
|
class="currency-option"
|
||||||
|
[class.active]="languageService.currentCurrency() === cur.code"
|
||||||
|
(click)="selectCurrency(cur)">
|
||||||
|
<span class="cur-symbol">{{ cur.symbol }}</span>
|
||||||
|
<span class="cur-name">{{ cur.name }}</span>
|
||||||
|
<span class="cur-code">{{ cur.code }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -301,3 +301,162 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Currency selector ──
|
||||||
|
.language-selector {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency-symbol {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency-code {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
opacity: 0.7;
|
||||||
|
&.rotated { transform: rotate(180deg); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 170px;
|
||||||
|
background: var(--card-bg, #1a1a1a);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cur-symbol {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cur-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cur-code {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light / Novo / Dexar theme adjustments for currency
|
||||||
|
:host-context(.novo-header),
|
||||||
|
:host-context(.header) {
|
||||||
|
.currency-button {
|
||||||
|
border-color: rgba(0, 0, 0, 0.2);
|
||||||
|
color: #333333;
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.light-theme) {
|
||||||
|
.currency-dropdown {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.currency-option {
|
||||||
|
color: #333333;
|
||||||
|
&:hover { background: rgba(0, 0, 0, 0.05); }
|
||||||
|
&.active { background: rgba(0, 0, 0, 0.1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dexar-header),
|
||||||
|
:host-context(.dexar-mobile-menu) {
|
||||||
|
.currency-button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
gap: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border: 1px solid #677b78;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1e3c38;
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
.currency-symbol { font-size: 14px; color: #1e3c38; }
|
||||||
|
.currency-code { font-size: 13px; color: #1e3c38; }
|
||||||
|
.dropdown-arrow path { stroke: #1e3c38; }
|
||||||
|
}
|
||||||
|
.currency-dropdown {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #d3dad9;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.currency-option {
|
||||||
|
color: #1e3c38;
|
||||||
|
&:hover { background: rgba(161, 180, 181, 0.2); }
|
||||||
|
&.active { background: rgba(73, 118, 113, 0.1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, HostListener, ElementRef, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, HostListener, ElementRef, ChangeDetectionStrategy } from '@angular/core';
|
||||||
import { LanguageService, Language } from '../../services/language.service';
|
import { LanguageService, Language, Currency } from '../../services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-language-selector',
|
selector: 'app-language-selector',
|
||||||
@@ -10,6 +10,7 @@ import { LanguageService, Language } from '../../services/language.service';
|
|||||||
})
|
})
|
||||||
export class LanguageSelectorComponent {
|
export class LanguageSelectorComponent {
|
||||||
dropdownOpen = false;
|
dropdownOpen = false;
|
||||||
|
currencyOpen = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public languageService: LanguageService,
|
public languageService: LanguageService,
|
||||||
@@ -18,6 +19,12 @@ export class LanguageSelectorComponent {
|
|||||||
|
|
||||||
toggleDropdown(): void {
|
toggleDropdown(): void {
|
||||||
this.dropdownOpen = !this.dropdownOpen;
|
this.dropdownOpen = !this.dropdownOpen;
|
||||||
|
this.currencyOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCurrency(): void {
|
||||||
|
this.currencyOpen = !this.currencyOpen;
|
||||||
|
this.dropdownOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectLanguage(lang: Language): void {
|
selectLanguage(lang: Language): void {
|
||||||
@@ -27,14 +34,21 @@ export class LanguageSelectorComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectCurrency(currency: Currency): void {
|
||||||
|
this.languageService.setCurrency(currency.code);
|
||||||
|
this.currencyOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
closeDropdown(): void {
|
closeDropdown(): void {
|
||||||
this.dropdownOpen = false;
|
this.dropdownOpen = false;
|
||||||
|
this.currencyOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
onClickOutside(event: Event): void {
|
onClickOutside(event: Event): void {
|
||||||
if (!this.elementRef.nativeElement.contains(event.target)) {
|
if (!this.elementRef.nativeElement.contains(event.target)) {
|
||||||
this.dropdownOpen = false;
|
this.dropdownOpen = false;
|
||||||
|
this.currencyOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<div class="region-selector">
|
||||||
|
<button class="region-trigger" (click)="toggleDropdown()" [class.active]="dropdownOpen()">
|
||||||
|
<svg class="pin-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||||
|
<circle cx="12" cy="10" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="region-name">
|
||||||
|
@if (detecting()) {
|
||||||
|
<span class="detecting">...</span>
|
||||||
|
} @else if (region()) {
|
||||||
|
{{ region()!.city }}
|
||||||
|
} @else {
|
||||||
|
{{ 'location.allRegions' | translate }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<svg class="chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
||||||
|
[class.rotated]="dropdownOpen()">
|
||||||
|
<path d="M6 9l6 6 6-6"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (dropdownOpen()) {
|
||||||
|
<div class="region-dropdown">
|
||||||
|
<div class="dropdown-header">
|
||||||
|
<span>{{ 'location.chooseRegion' | translate }}</span>
|
||||||
|
@if (!detecting()) {
|
||||||
|
<button class="detect-btn" (click)="detectLocation()" title="{{ 'location.detectAuto' | translate }}">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="region-list">
|
||||||
|
<button class="region-option" [class.selected]="!region()" (click)="selectGlobal()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ 'location.allRegions' | translate }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@for (r of regions(); track r.id) {
|
||||||
|
<button class="region-option" [class.selected]="region()?.id === r.id" (click)="selectRegion(r)">
|
||||||
|
<span class="region-city">{{ r.city }}</span>
|
||||||
|
<span class="region-country">{{ r.country }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
.region-selector {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover, &.active {
|
||||||
|
border-color: var(--accent-color, #497671);
|
||||||
|
background: var(--bg-hover, rgba(73, 118, 113, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--accent-color, #497671);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-name {
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
.detecting {
|
||||||
|
animation: pulse 1s ease infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 220px;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideDown 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-hover, rgba(73, 118, 113, 0.08));
|
||||||
|
color: var(--accent-color, #497671);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--accent-color, #497671);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-list {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover, rgba(73, 118, 113, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: var(--accent-color, #497671);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.region-country {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-city {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-country {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile adjustments
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.region-trigger {
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.region-name {
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-dropdown {
|
||||||
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
min-width: 100%;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
max-height: 60vh;
|
||||||
|
|
||||||
|
.region-list {
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, inject, signal, HostListener } from '@angular/core';
|
||||||
|
import { LocationService } from '../../services/location.service';
|
||||||
|
import { Region } from '../../models/location.model';
|
||||||
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-region-selector',
|
||||||
|
imports: [TranslatePipe],
|
||||||
|
templateUrl: './region-selector.component.html',
|
||||||
|
styleUrls: ['./region-selector.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class RegionSelectorComponent {
|
||||||
|
private locationService = inject(LocationService);
|
||||||
|
|
||||||
|
region = this.locationService.region;
|
||||||
|
regions = this.locationService.regions;
|
||||||
|
detecting = this.locationService.detecting;
|
||||||
|
|
||||||
|
dropdownOpen = signal(false);
|
||||||
|
|
||||||
|
toggleDropdown(): void {
|
||||||
|
this.dropdownOpen.update(v => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectRegion(region: Region): void {
|
||||||
|
this.locationService.setRegion(region);
|
||||||
|
this.dropdownOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectGlobal(): void {
|
||||||
|
this.locationService.clearRegion();
|
||||||
|
this.dropdownOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
detectLocation(): void {
|
||||||
|
this.locationService.detectLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
onDocumentClick(event: MouseEvent): void {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('app-region-selector')) {
|
||||||
|
this.dropdownOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@if (showDialog()) {
|
||||||
|
<div class="login-overlay" (click)="close()">
|
||||||
|
<div class="login-dialog" (click)="$event.stopPropagation()">
|
||||||
|
<button class="close-btn" (click)="close()">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="login-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{{ 'auth.loginRequired' | translate }}</h2>
|
||||||
|
<p class="login-desc">{{ 'auth.loginDescription' | translate }}</p>
|
||||||
|
|
||||||
|
@if (status() === 'checking') {
|
||||||
|
<div class="login-status checking">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>{{ 'auth.checking' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<button class="telegram-btn" (click)="openTelegramLogin()">
|
||||||
|
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||||
|
</svg>
|
||||||
|
{{ 'auth.loginWithTelegram' | translate }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="qr-section">
|
||||||
|
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
|
||||||
|
<div class="qr-container">
|
||||||
|
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + loginUrl()"
|
||||||
|
alt="QR Code"
|
||||||
|
width="180"
|
||||||
|
height="180"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
184
src/app/components/telegram-login/telegram-login.component.scss
Normal file
184
src/app/components/telegram-login/telegram-login.component.scss
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
.login-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-dialog {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
animation: scaleIn 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-hover, #f0f0f0);
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-icon {
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-light, rgba(73, 118, 113, 0.1));
|
||||||
|
color: var(--accent-color, #497671);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-desc {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #2AABEE;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #229ED9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
|
||||||
|
.qr-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-container {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-note {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-top-color: var(--accent-color, #497671);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-dialog {
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-section .qr-container img {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-telegram-login',
|
||||||
|
imports: [TranslatePipe],
|
||||||
|
templateUrl: './telegram-login.component.html',
|
||||||
|
styleUrls: ['./telegram-login.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class TelegramLoginComponent implements OnInit, OnDestroy {
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
|
||||||
|
showDialog = this.authService.showLoginDialog;
|
||||||
|
status = this.authService.status;
|
||||||
|
loginUrl = signal('');
|
||||||
|
|
||||||
|
private pollTimer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loginUrl.set(this.authService.getTelegramLoginUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.authService.hideLogin();
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open Telegram login link and start polling for session */
|
||||||
|
openTelegramLogin(): void {
|
||||||
|
window.open(this.loginUrl(), '_blank');
|
||||||
|
this.startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start polling the backend to detect when user completes Telegram auth */
|
||||||
|
private startPolling(): void {
|
||||||
|
this.stopPolling();
|
||||||
|
// Check every 3 seconds for up to 5 minutes
|
||||||
|
let checks = 0;
|
||||||
|
this.pollTimer = setInterval(() => {
|
||||||
|
checks++;
|
||||||
|
if (checks > 100) { // 100 * 3s = 5 min
|
||||||
|
this.stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.authService.checkSession();
|
||||||
|
// If authenticated, stop polling and close dialog
|
||||||
|
if (this.authService.isAuthenticated()) {
|
||||||
|
this.stopPolling();
|
||||||
|
this.authService.hideLogin();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling(): void {
|
||||||
|
if (this.pollTimer) {
|
||||||
|
clearInterval(this.pollTimer);
|
||||||
|
this.pollTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,10 @@ export const en: Translations = {
|
|||||||
emailNeedsAt: 'Email must contain @',
|
emailNeedsAt: 'Email must contain @',
|
||||||
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
|
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
|
||||||
emailInvalid: 'Invalid email format',
|
emailInvalid: 'Invalid email format',
|
||||||
|
loginRequired: 'Log in to checkout',
|
||||||
|
loginRequiredDesc: 'Please log in via Telegram to place your order',
|
||||||
|
loginWithTelegram: 'Log in with Telegram',
|
||||||
|
orScanQr: 'Or scan the QR code',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
title: 'Product search',
|
title: 'Product search',
|
||||||
@@ -134,6 +138,7 @@ export const en: Translations = {
|
|||||||
emptyTitle: 'Oops! No subcategories yet',
|
emptyTitle: 'Oops! No subcategories yet',
|
||||||
emptyDesc: 'There are no subcategories in this section yet, but they will appear soon',
|
emptyDesc: 'There are no subcategories in this section yet, but they will appear soon',
|
||||||
goHome: 'Go home',
|
goHome: 'Go home',
|
||||||
|
itemsInCategory: 'Items in this category',
|
||||||
},
|
},
|
||||||
itemDetail: {
|
itemDetail: {
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
@@ -148,6 +153,7 @@ export const en: Translations = {
|
|||||||
mediumStock: 'Running low',
|
mediumStock: 'Running low',
|
||||||
addToCart: 'Add to cart',
|
addToCart: 'Add to cart',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
|
specifications: 'Specifications',
|
||||||
reviews: 'Reviews',
|
reviews: 'Reviews',
|
||||||
yourReview: 'Your review',
|
yourReview: 'Your review',
|
||||||
leaveReview: 'Leave a review',
|
leaveReview: 'Leave a review',
|
||||||
@@ -169,6 +175,8 @@ export const en: Translations = {
|
|||||||
yesterday: 'Yesterday',
|
yesterday: 'Yesterday',
|
||||||
daysAgo: 'd. ago',
|
daysAgo: 'd. ago',
|
||||||
weeksAgo: 'w. ago',
|
weeksAgo: 'w. ago',
|
||||||
|
colour: 'Colour',
|
||||||
|
size: 'Size',
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
connecting: 'Connecting to server...',
|
connecting: 'Connecting to server...',
|
||||||
@@ -185,4 +193,17 @@ export const en: Translations = {
|
|||||||
retry: 'Try again',
|
retry: 'Try again',
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
},
|
},
|
||||||
|
location: {
|
||||||
|
allRegions: 'All regions',
|
||||||
|
chooseRegion: 'Choose region',
|
||||||
|
detectAuto: 'Detect automatically',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
loginRequired: 'Login required',
|
||||||
|
loginDescription: 'Please log in via Telegram to proceed with your order',
|
||||||
|
checking: 'Checking...',
|
||||||
|
loginWithTelegram: 'Log in with Telegram',
|
||||||
|
orScanQr: 'Or scan the QR code',
|
||||||
|
loginNote: 'You will be redirected back after login',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const hy: Translations = {
|
|||||||
search: 'Որոնում',
|
search: 'Որոնում',
|
||||||
about: 'Մեր մասին',
|
about: 'Մեր մասին',
|
||||||
contacts: 'Կապ',
|
contacts: 'Կապ',
|
||||||
searchPlaceholder: 'Որոնել...',
|
searchPlaceholder: 'Փնտրել...',
|
||||||
catalog: 'Կատալոգ',
|
catalog: 'Կատալոգ',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
@@ -14,7 +14,7 @@ export const hy: Translations = {
|
|||||||
company: 'Ընկերություն',
|
company: 'Ընկերություն',
|
||||||
aboutUs: 'Մեր մասին',
|
aboutUs: 'Մեր մասին',
|
||||||
contacts: 'Կապ',
|
contacts: 'Կապ',
|
||||||
requisites: 'Վավերապայմաններ',
|
requisites: 'Վճարային տվյալներ',
|
||||||
support: 'Աջակցություն',
|
support: 'Աջակցություն',
|
||||||
faq: 'ՀՏՀ',
|
faq: 'ՀՏՀ',
|
||||||
delivery: 'Առաքում',
|
delivery: 'Առաքում',
|
||||||
@@ -35,133 +35,139 @@ export const hy: Translations = {
|
|||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
welcomeTo: 'Բարի գալուստ {{brand}}',
|
welcomeTo: 'Բարի գալուստ {{brand}}',
|
||||||
subtitle: 'Գտեք ամեն ինչ մեկ վայրում',
|
subtitle: 'Գտեք այն ամենը, ինչ պետք է՝ մեկ վայրում',
|
||||||
startSearch: 'Սկսել որոնումը',
|
startSearch: 'Սկսել որոնումը',
|
||||||
loading: 'Կատեգորիաները բեռնվում են...',
|
loading: 'Բեռնում ենք կատեգորիաները...',
|
||||||
errorTitle: 'Ինչ-որ բան սխալ է գնացել',
|
errorTitle: 'Ինչ-որ բան սխալ գնաց',
|
||||||
retry: 'Փորձել կրկին',
|
retry: 'Փորձել կրկին',
|
||||||
categoriesTitle: 'Ապրանքների կատեգորիաներ',
|
categoriesTitle: 'Ապրանքների կատեգորիաներ',
|
||||||
categoriesSubtitle: 'Ընտրեք հետաքրքրող կատեգորիան',
|
categoriesSubtitle: 'Ընտրեք ձեզ հետաքրքիր կատեգորիան',
|
||||||
categoriesEmpty: 'Կատեգորիաները շուտով կհայտնվեն',
|
categoriesEmpty: 'Կատեգորիաները շուտով կհայտնվեն',
|
||||||
categoriesEmptyDesc: 'Մենք աշխատում ենք կատալոգի համալրման վրա',
|
categoriesEmptyDesc: 'Մենք աշխատում ենք կատալոգի լրացման վրա',
|
||||||
dexarHeroTitle: 'Այստեղ դու կգտնես ամեն ինչ',
|
dexarHeroTitle: 'Այստեղ կգտնես ամեն ինչ',
|
||||||
dexarHeroSubtitle: 'Հազարավոր ապրանքներ մեկ վայրում',
|
dexarHeroSubtitle: 'Հազարավոր ապրանքներ մեկ վայրում',
|
||||||
dexarHeroTagline: 'պարզ և հարմար',
|
dexarHeroTagline: 'պարզ և հարմար',
|
||||||
goToCatalog: 'Անցնել կատալոգ',
|
goToCatalog: 'Գնալ կատալոգ',
|
||||||
findProduct: 'Գտնել ապրանք',
|
findProduct: 'Գտնել ապրանք',
|
||||||
loadingDexar: 'Կատեգորիաները բեռնվում են...',
|
loadingDexar: 'Կատեգորիաների բեռնում...',
|
||||||
catalogTitle: 'Ապրանքների կատալոգ',
|
catalogTitle: 'Ապրանքների կատալոգ',
|
||||||
emptyCategoriesDexar: 'Կատեգորիաները դեռ չկան',
|
emptyCategoriesDexar: 'Կատեգորիաները դեռ չկան',
|
||||||
categoriesSoonDexar: 'Շուտով այստեղ կհայտնվեն ապրանքների կատեգորիաներ',
|
categoriesSoonDexar: 'Շուտով այստեղ կհայտնվեն կատեգորիաներ',
|
||||||
itemsCount: '{{count}} ապրանք',
|
itemsCount: '{{count}} ապրանք',
|
||||||
},
|
},
|
||||||
cart: {
|
cart: {
|
||||||
title: 'Զամբյուղ',
|
title: 'Զամբյուղ',
|
||||||
clear: 'Մաքրել',
|
clear: 'Մաքրել',
|
||||||
empty: 'Զամբյուղը դատարկ է',
|
empty: 'Զամբյուղը դատարկ է',
|
||||||
emptyDesc: 'Ավելացրեք ապրանքներ գնումները սկսելու համար',
|
emptyDesc: 'Ավելացրեք ապրանքներ՝ գնումները սկսելու համար',
|
||||||
goShopping: 'Անցնել գնումների',
|
goShopping: 'Գնալ գնումների',
|
||||||
total: 'Ընդամենը',
|
total: 'Ընդամենը',
|
||||||
items: 'Ապրանքներ',
|
items: 'Ապրանքներ',
|
||||||
deliveryLabel: 'Առաքում',
|
deliveryLabel: 'Առաքում',
|
||||||
toPay: 'Վճարման ենթակա',
|
toPay: 'Վճարման ենթակա',
|
||||||
agreeWith: 'Ես համաձայն եմ',
|
agreeWith: 'Ես համաձայն եմ',
|
||||||
publicOffer: 'հանրային օֆերտային',
|
publicOffer: 'հանրային օֆերտայի',
|
||||||
returnPolicy: 'վերադարձի քաղաքականությանը',
|
returnPolicy: 'վերադարձի քաղաքականության',
|
||||||
guaranteeTerms: 'երաշխիքային պայմաններին',
|
guaranteeTerms: 'երաշխիքային պայմանների',
|
||||||
privacyPolicy: 'գաղտնիության քաղաքականությանը',
|
privacyPolicy: 'գաղտնիության քաղաքականության',
|
||||||
and: 'և',
|
and: 'և',
|
||||||
checkout: 'Ձևակերպել պատվեր',
|
checkout: 'Ձևակերպել պատվերը',
|
||||||
close: 'Փակել',
|
close: 'Փակել',
|
||||||
creatingPayment: 'Վճարումը ստեղծվում է...',
|
creatingPayment: 'Վճարման ստեղծում...',
|
||||||
waitFewSeconds: 'Սպասեք մի քանի վայրկյան',
|
waitFewSeconds: 'Խնդրում ենք սպասել մի քանի վայրկյան',
|
||||||
scanQr: 'Սկանավորեք QR կոդը վճարման համար',
|
scanQr: 'Սքանավորեք QR կոդը վճարման համար',
|
||||||
amountToPay: 'Վճարման գումարը՝',
|
amountToPay: 'Վճարման գումար՝',
|
||||||
waitingPayment: 'Սպասում ենք վճարմանը...',
|
waitingPayment: 'Սպասում ենք վճարմանը...',
|
||||||
copied: '✓ Պատճենված է',
|
copied: '✓ Պատճենված է',
|
||||||
copyLink: 'Պատճենել հղումը',
|
copyLink: 'Պատճենել հղումը',
|
||||||
openNewTab: 'Բացել նոր ներդիրում',
|
openNewTab: 'Բացել նոր ներդիրում',
|
||||||
paymentSuccess: 'Շնորհավորում ենք։ Վճարումը հաջողությամբ կատարվել է։',
|
paymentSuccess: 'Շնորհավորում ենք! Վճարումը հաջող է անցել!',
|
||||||
paymentSuccessDesc: 'Մուտքագրեք ձեր կոնտակտային տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում',
|
paymentSuccessDesc: 'Մուտքագրեք ձեր տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում',
|
||||||
sending: 'Ուղարկվում է...',
|
sending: 'Ուղարկվում է...',
|
||||||
send: 'Ուղարկել',
|
send: 'Ուղարկել',
|
||||||
paymentTimeout: 'Սպասման ժամանակը սպառվել է',
|
paymentTimeout: 'Ժամանակը սպառվեց',
|
||||||
paymentTimeoutDesc: 'Մենք չենք ստացել վճարման հաստատում 3 րոպեի ընթացքում։',
|
paymentTimeoutDesc: 'Մենք չստացանք վճարման հաստատում 3 րոպեի ընթացքում։',
|
||||||
autoClose: 'Պատուհանը կփակվի ավտոմատ...',
|
autoClose: 'Պատուհանը կփակվի ավտոմատ...',
|
||||||
confirmClear: 'Համոզվա՞ծ եք, որ ցանկանում եք մաքրել զամբյուղը։',
|
confirmClear: 'Վստա՞հ եք, որ ցանկանում եք մաքրել զամբյուղը',
|
||||||
acceptTerms: 'Խնդրում ենք ընդունել օֆերտայի, վերադարձի և երաշխիքի պայմանները պատվերը հաստատելու համար։',
|
acceptTerms: 'Խնդրում ենք ընդունել պայմանները՝ պատվերը հաստատելու համար։',
|
||||||
copyError: 'Պատճենման սխալ՝',
|
copyError: 'Պատճենման սխալ՝',
|
||||||
emailSuccess: 'Email-ը հաջողությամբ ուղարկվել է։ Ստուգեք ձեր փոստը։',
|
emailSuccess: 'Email-ը հաջողությամբ ուղարկվեց։ Ստուգեք ձեր փոստը։',
|
||||||
emailError: 'Email ուղարկելու ժամանակ տեղի ունեցավ սխալ։ Խնդրում ենք փորձել կրկին։',
|
emailError: 'Սխալ email ուղարկելիս։ Խնդրում ենք փորձել կրկին։',
|
||||||
phoneRequired: 'Հեռախոսահամարը պարտադիր է',
|
phoneRequired: 'Հեռախոսահամարը պարտադիր է',
|
||||||
phoneMoreDigits: 'Մուտքագրեք ևս {{count}} թիվ',
|
phoneMoreDigits: 'Մուտքագրեք ևս {{count}} թիվ',
|
||||||
phoneTooMany: 'Չափազանց շատ թվեր',
|
phoneTooMany: 'Չափազանց շատ թվեր',
|
||||||
emailRequired: 'Email-ը պարտադիր է',
|
emailRequired: 'Email-ը պարտադիր է',
|
||||||
emailTooShort: 'Email-ը չափազանց կարճ է (նվազագույնը 5 նիշ)',
|
emailTooShort: 'Email-ը չափազանց կարճ է (առնվազն 5 նիշ)',
|
||||||
emailTooLong: 'Email-ը չափազանց երկար է (առավելագույնը 100 նիշ)',
|
emailTooLong: 'Email-ը չափազանց երկար է (առավելագույնը 100 նիշ)',
|
||||||
emailNeedsAt: 'Email-ը պետք է պարունակի @ նշանը',
|
emailNeedsAt: 'Email-ը պետք է պարունակի @',
|
||||||
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեն (.com, .ru և այլն)',
|
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
|
||||||
emailInvalid: 'Email-ի ձևաչափը սխալ է',
|
emailInvalid: 'Սխալ email ձևաչափ',
|
||||||
|
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
|
||||||
|
loginRequiredDesc: 'Պատվեր ձևակերպելու համար մուտք գործեք Telegram-ով',
|
||||||
|
loginWithTelegram: 'Մուտք Telegram-ով',
|
||||||
|
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
title: 'Ապրանքների որոնում',
|
title: 'Ապրանքների որոնում',
|
||||||
placeholder: 'Մուտքագրեք ապրանքի անունը...',
|
placeholder: 'Մուտքագրեք ապրանքի անվանումը...',
|
||||||
resultsCount: 'Գտնված ապրանքներ՝',
|
resultsCount: 'Գտնված ապրանքներ՝',
|
||||||
searching: 'Որոնում...',
|
searching: 'Որոնում...',
|
||||||
retry: 'Փորձել կրկին',
|
retry: 'Փորձել կրկին',
|
||||||
noResults: 'Ոչինչ չի գտնվել',
|
noResults: 'Ոչինչ չի գտնվել',
|
||||||
noResultsFor: '"{{query}}" հարցման համար ապրանքներ չեն գտնվել',
|
noResultsFor: '"{{query}}" հարցմամբ ապրանքներ չեն գտնվել',
|
||||||
noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր',
|
noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր',
|
||||||
addToCart: 'Ավելացնել զամբյուղ',
|
addToCart: 'Ավելացնել զամբյուղ',
|
||||||
loadingMore: 'Բեռնվում է...',
|
loadingMore: 'Բեռնում...',
|
||||||
allLoaded: 'Բոլոր արդյունքները բեռնված են',
|
allLoaded: 'Բոլոր արդյունքները բեռնված են',
|
||||||
emptyState: 'Մուտքագրեք հարցում ապրանքներ որոնելու համար',
|
emptyState: 'Մուտքագրեք հարցում որոնման համար',
|
||||||
of: 'ից',
|
of: '-ից',
|
||||||
},
|
},
|
||||||
category: {
|
category: {
|
||||||
retry: 'Փորձել կրկին',
|
retry: 'Փորձել կրկին',
|
||||||
addToCart: 'Ավելացնել զամբյուղ',
|
addToCart: 'Ավելացնել զամբյուղ',
|
||||||
loadingMore: 'Բեռնվում է...',
|
loadingMore: 'Բեռնում...',
|
||||||
allLoaded: 'Բոլոր ապրանքները բեռնված են',
|
allLoaded: 'Բոլոր ապրանքները բեռնված են',
|
||||||
emptyTitle: 'Ուպս։ Այստեղ դեռ դատարկ է',
|
emptyTitle: 'Վա՜յ, այստեղ դեռ դատարկ է',
|
||||||
emptyDesc: 'Այս կատեգորիայում դեռ ապրանքներ չկան, բայց շուտով կհայտնվեն',
|
emptyDesc: 'Այս կատեգորիայում դեռ ապրանքներ չկան',
|
||||||
goHome: 'Գլխավոր էջ',
|
goHome: 'Գլխավոր',
|
||||||
loading: 'Ապրանքները բեռնվում են...',
|
loading: 'Ապրանքների բեռնում...',
|
||||||
},
|
},
|
||||||
subcategories: {
|
subcategories: {
|
||||||
loading: 'Ենթակատեգորիաները բեռնվում են...',
|
loading: 'Ենթակատեգորիաների բեռնում...',
|
||||||
retry: 'Փորձել կրկին',
|
retry: 'Փորձել կրկին',
|
||||||
emptyTitle: 'Ուպս։ Ենթակատեգորիաներ դեռ չկան',
|
emptyTitle: 'Ենթակատեգորիաներ չկան',
|
||||||
emptyDesc: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան, բայց շուտով կհայտնվեն',
|
emptyDesc: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան',
|
||||||
goHome: 'Գլխավոր էջ',
|
goHome: 'Գլխավոր',
|
||||||
|
itemsInCategory: 'Ապրանքներ այս կատեգորիայում',
|
||||||
},
|
},
|
||||||
itemDetail: {
|
itemDetail: {
|
||||||
loading: 'Բեռնվում է...',
|
loading: 'Բեռնում...',
|
||||||
loadingDexar: 'Ապրանքը բեռնվում է...',
|
loadingDexar: 'Ապրանքի բեռնում...',
|
||||||
back: 'Վերադառնալ',
|
back: 'Վերադառնալ',
|
||||||
backHome: 'Վերադառնալ գլխավոր էջ',
|
backHome: 'Վերադառնալ գլխավոր էջ',
|
||||||
noImage: 'Պատկեր չկա',
|
noImage: 'Պատկեր չկա',
|
||||||
stock: 'Առկայություն՝',
|
stock: 'Առկայություն՝',
|
||||||
inStock: 'Առկա է',
|
inStock: 'Առկա է',
|
||||||
lowStock: 'Մնացել է քիչ',
|
lowStock: 'Քիչ է մնացել',
|
||||||
lastItems: 'Վերջին հատերը',
|
lastItems: 'Վերջին հատերը',
|
||||||
mediumStock: 'Վերջանում է',
|
mediumStock: 'Ավարտվում է',
|
||||||
addToCart: 'Ավելացնել զամբյուղ',
|
addToCart: 'Ավելացնել զամբյուղ',
|
||||||
description: 'Նկարագրություն',
|
description: 'Նկարագրություն',
|
||||||
|
specifications: 'Բնութագրեր',
|
||||||
reviews: 'Կարծիքներ',
|
reviews: 'Կարծիքներ',
|
||||||
yourReview: 'Ձեր կարծիքը',
|
yourReview: 'Ձեր կարծիքը',
|
||||||
leaveReview: 'Թողնել կարծիք',
|
leaveReview: 'Թողնել կարծիք',
|
||||||
rating: 'Գնահատական՝',
|
rating: 'Գնահատական՝',
|
||||||
reviewPlaceholder: 'Կիսվեք ձեր տպավորություններով ապրանքի մասին...',
|
reviewPlaceholder: 'Կիսվեք ձեր կարծիքով...',
|
||||||
reviewPlaceholderDexar: 'Կիսվեք ձեր տպավորություններով...',
|
reviewPlaceholderDexar: 'Կիսվեք տպավորություններով...',
|
||||||
anonymous: 'Անանուն',
|
anonymous: 'Անանուն',
|
||||||
submitting: 'Ուղարկվում է...',
|
submitting: 'Ուղարկվում է...',
|
||||||
submit: 'Ուղարկել',
|
submit: 'Ուղարկել',
|
||||||
reviewSuccess: 'Շնորհակալություն ձեր կարծիքի համար։',
|
reviewSuccess: 'Շնորհակալություն ձեր կարծիքի համար!',
|
||||||
reviewError: 'Ուղարկման սխալ։ Փորձեք ավելի ուշ։',
|
reviewError: 'Սխալ ուղարկելիս։ Փորձեք ավելի ուշ։',
|
||||||
defaultUser: 'Օգտատեր',
|
defaultUser: 'Օգտատեր',
|
||||||
defaultUserDexar: 'Անանուն',
|
defaultUserDexar: 'Անանուն',
|
||||||
noReviews: 'Դեռ կարծիքներ չկան։ Դարձեք առաջինը։',
|
noReviews: 'Կարծիքներ դեռ չկան',
|
||||||
qna: 'Հարցեր և պատասխաններ',
|
qna: 'Հարցեր և պատասխաններ',
|
||||||
photo: 'Լուսանկար',
|
photo: 'Լուսանկար',
|
||||||
reviewsCount: 'կարծիք',
|
reviewsCount: 'կարծիք',
|
||||||
@@ -169,20 +175,35 @@ export const hy: Translations = {
|
|||||||
yesterday: 'Երեկ',
|
yesterday: 'Երեկ',
|
||||||
daysAgo: 'օր առաջ',
|
daysAgo: 'օր առաջ',
|
||||||
weeksAgo: 'շաբաթ առաջ',
|
weeksAgo: 'շաբաթ առաջ',
|
||||||
|
colour: 'Գույն',
|
||||||
|
size: 'Չափ',
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
connecting: 'Միացում սերվերին...',
|
connecting: 'Կապ սերվերի հետ...',
|
||||||
serverUnavailable: 'Սերվերը հասանելի չէ',
|
serverUnavailable: 'Սերվերը անհասանելի է',
|
||||||
serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետ կապը։',
|
serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետը։',
|
||||||
retryConnection: 'Կրկնել փորձը',
|
retryConnection: 'Փորձել կրկին',
|
||||||
pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս',
|
pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս',
|
||||||
},
|
},
|
||||||
carousel: {
|
carousel: {
|
||||||
loading: 'Ապրանքները բեռնվում են...',
|
loading: 'Ապրանքների բեռնում...',
|
||||||
addToCart: 'Ավելացնել զամբյուղ',
|
addToCart: 'Ավելացնել զամբյուղ',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
retry: 'Փորձել կրկին',
|
retry: 'Փորձել կրկին',
|
||||||
loading: 'Բեռնվում է...',
|
loading: 'Բեռնում...',
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
allRegions: 'Բոլոր տարածաշրջանները',
|
||||||
|
chooseRegion: 'Ընտրեք տարածաշրջանը',
|
||||||
|
detectAuto: 'Որոշել ավտոմատ',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
loginRequired: 'Պահանջվում է մուտք',
|
||||||
|
loginDescription: 'Պատվերի համար մուտք գործեք Telegram-ով',
|
||||||
|
checking: 'Ստուգում...',
|
||||||
|
loginWithTelegram: 'Մուտք Telegram-ով',
|
||||||
|
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
||||||
|
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ export const ru: Translations = {
|
|||||||
emailNeedsAt: 'Email должен содержать @',
|
emailNeedsAt: 'Email должен содержать @',
|
||||||
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
||||||
emailInvalid: 'Некорректный формат email',
|
emailInvalid: 'Некорректный формат email',
|
||||||
|
loginRequired: 'Войдите для оформления',
|
||||||
|
loginRequiredDesc: 'Для оформления заказа войдите через Telegram',
|
||||||
|
loginWithTelegram: 'Войти через Telegram',
|
||||||
|
orScanQr: 'Или отсканируйте QR-код',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
title: 'Поиск товаров',
|
title: 'Поиск товаров',
|
||||||
@@ -134,6 +138,7 @@ export const ru: Translations = {
|
|||||||
emptyTitle: 'Упс! Подкатегорий пока нет',
|
emptyTitle: 'Упс! Подкатегорий пока нет',
|
||||||
emptyDesc: 'В этом разделе ещё нет подкатегорий, но скоро они появятся',
|
emptyDesc: 'В этом разделе ещё нет подкатегорий, но скоро они появятся',
|
||||||
goHome: 'На главную',
|
goHome: 'На главную',
|
||||||
|
itemsInCategory: 'Товары в этой категории',
|
||||||
},
|
},
|
||||||
itemDetail: {
|
itemDetail: {
|
||||||
loading: 'Загрузка...',
|
loading: 'Загрузка...',
|
||||||
@@ -148,6 +153,7 @@ export const ru: Translations = {
|
|||||||
mediumStock: 'Заканчивается',
|
mediumStock: 'Заканчивается',
|
||||||
addToCart: 'Добавить в корзину',
|
addToCart: 'Добавить в корзину',
|
||||||
description: 'Описание',
|
description: 'Описание',
|
||||||
|
specifications: 'Характеристики',
|
||||||
reviews: 'Отзывы',
|
reviews: 'Отзывы',
|
||||||
yourReview: 'Ваш отзыв',
|
yourReview: 'Ваш отзыв',
|
||||||
leaveReview: 'Оставить отзыв',
|
leaveReview: 'Оставить отзыв',
|
||||||
@@ -169,6 +175,8 @@ export const ru: Translations = {
|
|||||||
yesterday: 'Вчера',
|
yesterday: 'Вчера',
|
||||||
daysAgo: 'дн. назад',
|
daysAgo: 'дн. назад',
|
||||||
weeksAgo: 'нед. назад',
|
weeksAgo: 'нед. назад',
|
||||||
|
colour: 'Цвет',
|
||||||
|
size: 'Размер',
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
connecting: 'Подключение к серверу...',
|
connecting: 'Подключение к серверу...',
|
||||||
@@ -185,4 +193,17 @@ export const ru: Translations = {
|
|||||||
retry: 'Попробовать снова',
|
retry: 'Попробовать снова',
|
||||||
loading: 'Загрузка...',
|
loading: 'Загрузка...',
|
||||||
},
|
},
|
||||||
|
location: {
|
||||||
|
allRegions: 'Все регионы',
|
||||||
|
chooseRegion: 'Выберите регион',
|
||||||
|
detectAuto: 'Определить автоматически',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
loginRequired: 'Требуется авторизация',
|
||||||
|
loginDescription: 'Для оформления заказа войдите через Telegram',
|
||||||
|
checking: 'Проверка...',
|
||||||
|
loginWithTelegram: 'Войти через Telegram',
|
||||||
|
orScanQr: 'Или отсканируйте QR-код',
|
||||||
|
loginNote: 'После входа вы будете перенаправлены обратно',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ export interface Translations {
|
|||||||
emailNeedsAt: string;
|
emailNeedsAt: string;
|
||||||
emailNeedsDomain: string;
|
emailNeedsDomain: string;
|
||||||
emailInvalid: string;
|
emailInvalid: string;
|
||||||
|
loginRequired: string;
|
||||||
|
loginRequiredDesc: string;
|
||||||
|
loginWithTelegram: string;
|
||||||
|
orScanQr: string;
|
||||||
};
|
};
|
||||||
search: {
|
search: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -132,6 +136,7 @@ export interface Translations {
|
|||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDesc: string;
|
emptyDesc: string;
|
||||||
goHome: string;
|
goHome: string;
|
||||||
|
itemsInCategory: string;
|
||||||
};
|
};
|
||||||
itemDetail: {
|
itemDetail: {
|
||||||
loading: string;
|
loading: string;
|
||||||
@@ -146,6 +151,7 @@ export interface Translations {
|
|||||||
mediumStock: string;
|
mediumStock: string;
|
||||||
addToCart: string;
|
addToCart: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
specifications: string;
|
||||||
reviews: string;
|
reviews: string;
|
||||||
yourReview: string;
|
yourReview: string;
|
||||||
leaveReview: string;
|
leaveReview: string;
|
||||||
@@ -167,6 +173,8 @@ export interface Translations {
|
|||||||
yesterday: string;
|
yesterday: string;
|
||||||
daysAgo: string;
|
daysAgo: string;
|
||||||
weeksAgo: string;
|
weeksAgo: string;
|
||||||
|
colour: string;
|
||||||
|
size: string;
|
||||||
};
|
};
|
||||||
app: {
|
app: {
|
||||||
connecting: string;
|
connecting: string;
|
||||||
@@ -183,4 +191,17 @@ export interface Translations {
|
|||||||
retry: string;
|
retry: string;
|
||||||
loading: string;
|
loading: string;
|
||||||
};
|
};
|
||||||
|
location: {
|
||||||
|
allRegions: string;
|
||||||
|
chooseRegion: string;
|
||||||
|
detectAuto: string;
|
||||||
|
};
|
||||||
|
auth: {
|
||||||
|
loginRequired: string;
|
||||||
|
loginDescription: string;
|
||||||
|
checking: string;
|
||||||
|
loginWithTelegram: string;
|
||||||
|
orScanQr: string;
|
||||||
|
loginNote: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/app/interceptors/api-headers.interceptor.ts
Normal file
50
src/app/interceptors/api-headers.interceptor.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { LocationService } from '../services/location.service';
|
||||||
|
import { LanguageService } from '../services/language.service';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
/** Map internal language codes to API header values */
|
||||||
|
const LANG_HEADER_MAP: Record<string, string> = {
|
||||||
|
'ru': 'RU',
|
||||||
|
'en': 'EN',
|
||||||
|
'hy': 'AM',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map region IDs to API header values */
|
||||||
|
const REGION_HEADER_MAP: Record<string, string> = {
|
||||||
|
'moscow': 'Moscow',
|
||||||
|
'spb': 'ST. Petersburg',
|
||||||
|
'yerevan': 'Yerevan',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
if (!req.url.startsWith(environment.apiUrl)) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationService = inject(LocationService);
|
||||||
|
const languageService = inject(LanguageService);
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
|
||||||
|
const regionId = locationService.regionId();
|
||||||
|
const lang = languageService.currentLanguage();
|
||||||
|
const currency = languageService.currentCurrency();
|
||||||
|
const session = authService.session();
|
||||||
|
|
||||||
|
let headers = req.headers;
|
||||||
|
|
||||||
|
if (regionId) {
|
||||||
|
headers = headers.set('X-Region', REGION_HEADER_MAP[regionId] ?? regionId);
|
||||||
|
}
|
||||||
|
if (lang) {
|
||||||
|
headers = headers.set('X-Language', LANG_HEADER_MAP[lang] ?? lang.toUpperCase());
|
||||||
|
}
|
||||||
|
headers = headers.set('Currency', currency || 'RUB');
|
||||||
|
if (session?.sessionId) {
|
||||||
|
headers = headers.set('WebSessionID', session.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(req.clone({ headers }));
|
||||||
|
};
|
||||||
795
src/app/interceptors/mock-data.interceptor.ts
Normal file
795
src/app/interceptors/mock-data.interceptor.ts
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
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'],
|
||||||
|
colour: 'Натуральный титан',
|
||||||
|
size: '',
|
||||||
|
names: [
|
||||||
|
{ language: 'ru', value: 'iPhone 15 Pro Max' },
|
||||||
|
{ language: 'en', value: 'iPhone 15 Pro Max' },
|
||||||
|
{ language: 'hy', value: 'iPhone 15 Pro Max' }
|
||||||
|
],
|
||||||
|
descriptions: [
|
||||||
|
{ language: 'ru', value: 'Новейший iPhone с титановым корпусом и чипом A17 Pro' },
|
||||||
|
{ language: 'en', value: 'Latest iPhone with titanium body and A17 Pro chip' }
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
{ key: 'Цвет', value: 'Натуральный титан' },
|
||||||
|
{ key: 'Память', value: '256 ГБ' },
|
||||||
|
{ key: 'Процессор', value: 'A17 Pro' }
|
||||||
|
],
|
||||||
|
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'],
|
||||||
|
colour: 'Титановый серый',
|
||||||
|
size: '',
|
||||||
|
names: [
|
||||||
|
{ language: 'ru', value: 'Samsung Galaxy S24 Ultra' },
|
||||||
|
{ language: 'en', value: 'Samsung Galaxy S24 Ultra' }
|
||||||
|
],
|
||||||
|
descriptions: [
|
||||||
|
{ language: 'ru', value: 'Премиальный флагман Samsung с S Pen' },
|
||||||
|
{ language: 'en', value: 'Premium Samsung flagship with S Pen' }
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
{ key: 'Память', value: '512 ГБ' },
|
||||||
|
{ key: 'ОЗУ', value: '12 ГБ' }
|
||||||
|
],
|
||||||
|
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);
|
||||||
|
};
|
||||||
20
src/app/models/auth.model.ts
Normal file
20
src/app/models/auth.model.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export interface AuthSession {
|
||||||
|
sessionId: string;
|
||||||
|
telegramUserId: number;
|
||||||
|
username: string | null;
|
||||||
|
displayName: string;
|
||||||
|
active: boolean;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelegramAuthData {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name?: string;
|
||||||
|
username?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
auth_date: number;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||||
@@ -6,4 +6,28 @@ export interface Category {
|
|||||||
wideBanner?: string;
|
wideBanner?: string;
|
||||||
itemCount?: number;
|
itemCount?: number;
|
||||||
priority?: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
export * from './category.model';
|
export * from './category.model';
|
||||||
export * from './item.model';
|
export * from './item.model';
|
||||||
|
export * from './location.model';
|
||||||
|
export * from './auth.model';
|
||||||
|
|||||||
@@ -5,6 +5,25 @@ export interface Photo {
|
|||||||
type?: string;
|
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 {
|
export interface Review {
|
||||||
rating?: number;
|
rating?: number;
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -23,6 +42,24 @@ export interface Question {
|
|||||||
downvotes: number;
|
downvotes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Localized name entry from backend */
|
||||||
|
export interface ItemName {
|
||||||
|
language: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Localized description entry from backend */
|
||||||
|
export interface ItemDescription {
|
||||||
|
language: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key-value attribute pair */
|
||||||
|
export interface ItemAttribute {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
categoryID: number;
|
categoryID: number;
|
||||||
itemID: number;
|
itemID: number;
|
||||||
@@ -36,7 +73,28 @@ export interface Item {
|
|||||||
rating: number;
|
rating: number;
|
||||||
callbacks: Review[] | null;
|
callbacks: Review[] | null;
|
||||||
questions: Question[] | null;
|
questions: Question[] | null;
|
||||||
partnerID?: string;
|
quantity?: number;
|
||||||
|
|
||||||
|
// Backend API fields
|
||||||
|
colour?: string;
|
||||||
|
size?: string;
|
||||||
|
language?: string;
|
||||||
|
names?: ItemName[];
|
||||||
|
descriptions?: ItemDescription[];
|
||||||
|
attributes?: ItemAttribute[];
|
||||||
|
|
||||||
|
// 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 {
|
export interface CartItem extends Item {
|
||||||
|
|||||||
17
src/app/models/location.model.ts
Normal file
17
src/app/models/location.model.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export interface Region {
|
||||||
|
id: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
countryCode: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoIpResponse {
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
countryCode: string;
|
||||||
|
region?: string;
|
||||||
|
timezone?: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
}
|
||||||
@@ -31,12 +31,12 @@
|
|||||||
(touchstart)="onSwipeStart(item.itemID, $event)">
|
(touchstart)="onSwipeStart(item.itemID, $event)">
|
||||||
<div class="cart-item">
|
<div class="cart-item">
|
||||||
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-image">
|
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-image">
|
||||||
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" />
|
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-header">
|
<div class="item-header">
|
||||||
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-name">{{ item.name }}</a>
|
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-name">{{ itemName(item) }}</a>
|
||||||
<button class="remove-btn" (click)="removeItem(item.itemID)" title="Remove">
|
<button class="remove-btn" (click)="removeItem(item.itemID)" title="Remove">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M18 6L6 18M6 6l12 12"/>
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
@@ -44,17 +44,36 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="item-description">{{ item.description.substring(0, 100) }}...</p>
|
<p class="item-description">{{ itemDesc(item) || '' }}...</p>
|
||||||
|
|
||||||
|
@if (item.colour || item.size) {
|
||||||
|
<div class="cart-item-variants">
|
||||||
|
@if (item.colour) {
|
||||||
|
<span class="cart-variant">{{ 'itemDetail.colour' | translate }}: {{ item.colour }}</span>
|
||||||
|
}
|
||||||
|
@if (item.size) {
|
||||||
|
<span class="cart-variant">{{ 'itemDetail.size' | translate }}: {{ item.size }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@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-footer">
|
||||||
<div class="item-pricing">
|
<div class="item-pricing">
|
||||||
@if (item.discount > 0) {
|
@if (item.discount > 0) {
|
||||||
<div class="price-with-discount">
|
<div class="price-with-discount">
|
||||||
<span class="original-price">{{ item.price }} ₽</span>
|
<span class="original-price">{{ item.price }} {{ item.currency }}</span>
|
||||||
<span class="current-price">{{ getDiscountedPrice(item) | number:'1.2-2' }} ₽</span>
|
<span class="current-price">{{ getDiscountedPrice(item) | number:'1.2-2' }} {{ item.currency }}</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="current-price">{{ item.price }} ₽</span>
|
<span class="current-price">{{ item.price }} {{ item.currency }}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,17 +110,17 @@
|
|||||||
|
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span>{{ 'cart.items' | translate }} ({{ itemCount() }})</span>
|
<span>{{ 'cart.items' | translate }} ({{ itemCount() }})</span>
|
||||||
<span class="value">{{ totalPrice() | number:'1.2-2' }} ₽</span>
|
<span class="value">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary-row delivery">
|
<div class="summary-row delivery">
|
||||||
<span>{{ 'cart.deliveryLabel' | translate }}</span>
|
<span>{{ 'cart.deliveryLabel' | translate }}</span>
|
||||||
<span>0 ₽</span>
|
<span>0 {{ currentCurrency }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary-row total">
|
<div class="summary-row total">
|
||||||
<span>{{ 'cart.toPay' | translate }}</span>
|
<span>{{ 'cart.toPay' | translate }}</span>
|
||||||
<span class="total-price">{{ totalPrice() | number:'1.2-2' }} ₽</span>
|
<span class="total-price">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="terms-agreement">
|
<div class="terms-agreement">
|
||||||
@@ -130,6 +149,36 @@
|
|||||||
>
|
>
|
||||||
{{ 'cart.checkout' | translate }}
|
{{ 'cart.checkout' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@if (!isAuthenticated()) {
|
||||||
|
<div class="cart-login-gate">
|
||||||
|
<div class="login-gate-icon">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="login-gate-title">{{ 'cart.loginRequired' | translate }}</p>
|
||||||
|
<p class="login-gate-desc">{{ 'cart.loginRequiredDesc' | translate }}</p>
|
||||||
|
|
||||||
|
<button class="telegram-login-btn" (click)="requestLogin()">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||||
|
</svg>
|
||||||
|
{{ 'cart.loginWithTelegram' | translate }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="login-gate-qr">
|
||||||
|
<p class="qr-hint">{{ 'cart.orScanQr' | translate }}</p>
|
||||||
|
<div class="qr-wrapper">
|
||||||
|
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=' + loginUrl()"
|
||||||
|
alt="QR Code"
|
||||||
|
width="150"
|
||||||
|
height="150"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -166,7 +215,7 @@
|
|||||||
<div class="payment-info">
|
<div class="payment-info">
|
||||||
<div class="payment-amount">
|
<div class="payment-amount">
|
||||||
<span class="label">{{ 'cart.amountToPay' | translate }}</span>
|
<span class="label">{{ 'cart.amountToPay' | translate }}</span>
|
||||||
<span class="amount">{{ totalPrice() | number:'1.2-2' }} RUB</span>
|
<span class="amount">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="waiting-indicator">
|
<div class="waiting-indicator">
|
||||||
@@ -256,3 +305,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<app-telegram-login />
|
||||||
|
|||||||
@@ -364,6 +364,22 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-item-variants {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
.cart-variant {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #497671;
|
||||||
|
background: rgba(73, 118, 113, 0.08);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.item-footer {
|
.item-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -464,6 +480,22 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-item-variants {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
.cart-variant {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6366f1;
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.item-footer {
|
.item-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -689,6 +721,85 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-login-gate {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(42, 171, 238, 0.05);
|
||||||
|
border: 1px dashed rgba(42, 171, 238, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.login-gate-icon {
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(42, 171, 238, 0.1);
|
||||||
|
color: #2AABEE;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-gate-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-gate-desc {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-login-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #2AABEE;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #229ED9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-gate-qr {
|
||||||
|
margin-top: 14px;
|
||||||
|
|
||||||
|
.qr-hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Novo Cart Summary - Green Modern
|
// Novo Cart Summary - Green Modern
|
||||||
|
|||||||
@@ -2,20 +2,21 @@ import { Component, computed, ChangeDetectionStrategy, signal, OnDestroy, inject
|
|||||||
import { DecimalPipe } from '@angular/common';
|
import { DecimalPipe } from '@angular/common';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { CartService, ApiService, LanguageService } from '../../services';
|
import { CartService, ApiService, LanguageService, AuthService } from '../../services';
|
||||||
import { Item, CartItem } from '../../models';
|
import { Item, CartItem } from '../../models';
|
||||||
import { interval, Subscription } from 'rxjs';
|
import { interval, Subscription } from 'rxjs';
|
||||||
import { switchMap, take } from 'rxjs/operators';
|
import { switchMap, take } from 'rxjs/operators';
|
||||||
import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component';
|
import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component';
|
||||||
|
import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
|
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../i18n/translate.service';
|
import { TranslateService } from '../../i18n/translate.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-cart',
|
selector: 'app-cart',
|
||||||
imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, LangRoutePipe, TranslatePipe],
|
imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe],
|
||||||
templateUrl: './cart.component.html',
|
templateUrl: './cart.component.html',
|
||||||
styleUrls: ['./cart.component.scss'],
|
styleUrls: ['./cart.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -28,6 +29,10 @@ export class CartComponent implements OnDestroy {
|
|||||||
isnovo = environment.theme === 'novo';
|
isnovo = environment.theme === 'novo';
|
||||||
|
|
||||||
private i18n = inject(TranslateService);
|
private i18n = inject(TranslateService);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
|
||||||
|
isAuthenticated = this.authService.isAuthenticated;
|
||||||
|
loginUrl = signal('');
|
||||||
|
|
||||||
// Swipe state
|
// Swipe state
|
||||||
swipedItemId = signal<number | null>(null);
|
swipedItemId = signal<number | null>(null);
|
||||||
@@ -63,6 +68,11 @@ export class CartComponent implements OnDestroy {
|
|||||||
this.items = this.cartService.items;
|
this.items = this.cartService.items;
|
||||||
this.itemCount = this.cartService.itemCount;
|
this.itemCount = this.cartService.itemCount;
|
||||||
this.totalPrice = this.cartService.totalPrice;
|
this.totalPrice = this.cartService.totalPrice;
|
||||||
|
this.loginUrl.set(this.authService.getTelegramLoginUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLogin(): void {
|
||||||
|
this.authService.requestLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -128,12 +138,22 @@ export class CartComponent implements OnDestroy {
|
|||||||
readonly getMainImage = getMainImage;
|
readonly getMainImage = getMainImage;
|
||||||
readonly trackByItemId = trackByItemId;
|
readonly trackByItemId = trackByItemId;
|
||||||
readonly getDiscountedPrice = getDiscountedPrice;
|
readonly getDiscountedPrice = getDiscountedPrice;
|
||||||
|
readonly getBadgeClass = getBadgeClass;
|
||||||
|
|
||||||
|
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
||||||
|
itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); }
|
||||||
|
get currentCurrency(): string { return this.langService.currentCurrency(); }
|
||||||
|
|
||||||
checkout(): void {
|
checkout(): void {
|
||||||
if (!this.termsAccepted) {
|
if (!this.termsAccepted) {
|
||||||
alert(this.i18n.t('cart.acceptTerms'));
|
alert(this.i18n.t('cart.acceptTerms'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Auth gate: require Telegram login before payment
|
||||||
|
if (!this.authService.isAuthenticated()) {
|
||||||
|
this.authService.requestLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.openPaymentPopup();
|
this.openPaymentPopup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +187,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
|
|
||||||
const paymentData = {
|
const paymentData = {
|
||||||
amount: this.totalPrice(),
|
amount: this.totalPrice(),
|
||||||
currency: 'RUB',
|
currency: this.langService.currentCurrency(),
|
||||||
siteuserID: userId,
|
siteuserID: userId,
|
||||||
siteorderID: orderId,
|
siteorderID: orderId,
|
||||||
redirectUrl: '',
|
redirectUrl: '',
|
||||||
|
|||||||
@@ -12,14 +12,21 @@
|
|||||||
<div class="item-card">
|
<div class="item-card">
|
||||||
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
||||||
<div class="item-image">
|
<div class="item-image">
|
||||||
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" width="300" height="300" />
|
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
|
||||||
@if (item.discount > 0) {
|
@if (item.discount > 0) {
|
||||||
<div class="discount-badge">-{{ item.discount }}%</div>
|
<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>
|
||||||
|
|
||||||
<div class="item-details">
|
<div class="item-details">
|
||||||
<h3 class="item-name">{{ item.name }}</h3>
|
<h3 class="item-name">{{ itemName(item) }}</h3>
|
||||||
|
|
||||||
<div class="item-rating">
|
<div class="item-rating">
|
||||||
<span class="rating-stars">⭐ {{ item.rating }}</span>
|
<span class="rating-stars">⭐ {{ item.rating }}</span>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||||
import { DecimalPipe } from '@angular/common';
|
import { DecimalPipe } from '@angular/common';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { ApiService, CartService } from '../../services';
|
import { ApiService, CartService } from '../../services';
|
||||||
import { Item } from '../../models';
|
import { Item } from '../../models';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
|
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||||
|
import { LanguageService } from '../../services/language.service';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
|||||||
hasMore = signal(true);
|
hasMore = signal(true);
|
||||||
|
|
||||||
private skip = 0;
|
private skip = 0;
|
||||||
private readonly count = 20;
|
private readonly count = 50;
|
||||||
private isLoadingMore = false;
|
private isLoadingMore = false;
|
||||||
private routeSubscription?: Subscription;
|
private routeSubscription?: Subscription;
|
||||||
private scrollTimeout?: ReturnType<typeof setTimeout>;
|
private scrollTimeout?: ReturnType<typeof setTimeout>;
|
||||||
@@ -107,4 +108,8 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
|||||||
readonly getDiscountedPrice = getDiscountedPrice;
|
readonly getDiscountedPrice = getDiscountedPrice;
|
||||||
readonly getMainImage = getMainImage;
|
readonly getMainImage = getMainImage;
|
||||||
readonly trackByItemId = trackByItemId;
|
readonly trackByItemId = trackByItemId;
|
||||||
|
readonly getBadgeClass = getBadgeClass;
|
||||||
|
|
||||||
|
private langService = inject(LanguageService);
|
||||||
|
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,30 @@
|
|||||||
<h2>{{ parentName() }}</h2>
|
<h2>{{ parentName() }}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Nested subcategories from API (backOffice format with hasItems) -->
|
||||||
|
@if (nestedSubcategories().length > 0) {
|
||||||
|
<div class="categories-grid">
|
||||||
|
@for (sub of nestedSubcategories(); track trackBySubId($index, sub)) {
|
||||||
|
<a [routerLink]="['/category', sub.id] | langRoute" class="category-card">
|
||||||
|
<div class="category-image">
|
||||||
|
@if (sub.img) {
|
||||||
|
<img [src]="sub.img" [alt]="sub.name" loading="lazy" decoding="async" />
|
||||||
|
} @else {
|
||||||
|
<div class="category-fallback">{{ sub.name.charAt(0) }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="category-info">
|
||||||
|
<h3 class="category-name">{{ sub.name }}</h3>
|
||||||
|
@if (sub.itemCount) {
|
||||||
|
<span class="category-count">{{ sub.itemCount }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Legacy flat subcategories -->
|
||||||
@if (subcategories().length > 0) {
|
@if (subcategories().length > 0) {
|
||||||
<div class="categories-grid">
|
<div class="categories-grid">
|
||||||
@for (cat of subcategories(); track trackByCategoryId($index, cat)) {
|
@for (cat of subcategories(); track trackByCategoryId($index, cat)) {
|
||||||
@@ -35,7 +59,53 @@
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Items directly in this category -->
|
||||||
|
@if (categoryItems().length > 0) {
|
||||||
|
<div class="category-items-section">
|
||||||
|
<h3 class="items-section-title">{{ 'subcategories.itemsInCategory' | translate }}</h3>
|
||||||
|
<div class="items-grid">
|
||||||
|
@for (item of categoryItems(); track trackByItemId($index, item)) {
|
||||||
|
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-card">
|
||||||
|
<div class="item-image">
|
||||||
|
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" />
|
||||||
|
@if (item.discount > 0) {
|
||||||
|
<span class="item-discount">-{{ item.discount }}%</span>
|
||||||
|
}
|
||||||
|
@if (item.badges && item.badges.length > 0) {
|
||||||
|
<div class="item-badges">
|
||||||
|
@for (badge of item.badges; track badge) {
|
||||||
|
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<h4 class="item-name">{{ itemName(item) }}</h4>
|
||||||
|
<div class="item-price">
|
||||||
|
@if (item.discount > 0) {
|
||||||
|
<span class="old-price">{{ item.price }} {{ item.currency }}</span>
|
||||||
|
<span class="current-price">{{ getDiscountedPrice(item) | number:'1.0-0' }} {{ item.currency }}</span>
|
||||||
} @else {
|
} @else {
|
||||||
|
<span class="current-price">{{ item.price }} {{ item.currency }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button class="item-cart-btn" (click)="addToCart(item.itemID, $event)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="9" cy="21" r="1"></circle>
|
||||||
|
<circle cx="20" cy="21" r="1"></circle>
|
||||||
|
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!hasSubcategories() && categoryItems().length === 0) {
|
||||||
<div class="no-subcats">
|
<div class="no-subcats">
|
||||||
<div class="no-subcats-icon">
|
<div class="no-subcats-icon">
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
@@ -235,6 +235,149 @@
|
|||||||
min-height: calc(2 * 1.3em);
|
min-height: calc(2 * 1.3em);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-count {
|
||||||
|
font-family: "DM Sans", sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #697777;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items section within subcategories page
|
||||||
|
.category-items-section {
|
||||||
|
margin-top: 40px;
|
||||||
|
|
||||||
|
.items-section-title {
|
||||||
|
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e3c38;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid #d3dad9;
|
||||||
|
border-radius: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-image {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card:hover & img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-discount {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-badges {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-family: "DM Sans", sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e3c38;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-price {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.old-price {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #a1b4b5;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-price {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e3c38;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-cart-btn {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: #497671;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #3a5f5b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keyframes
|
// Keyframes
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
@@ -248,6 +391,11 @@
|
|||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.items-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
@@ -273,6 +421,11 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.items-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.category-info {
|
.category-info {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
@@ -294,6 +447,11 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.items-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.category-info {
|
.category-info {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core';
|
import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||||
|
import { DecimalPipe } from '@angular/common';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { ApiService, LanguageService } from '../../services';
|
import { ApiService, CartService, LanguageService } from '../../services';
|
||||||
import { Category } from '../../models';
|
import { Category, Item, Subcategory } from '../../models';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../i18n/translate.service';
|
import { TranslateService } from '../../i18n/translate.service';
|
||||||
|
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-subcategories',
|
selector: 'app-subcategories',
|
||||||
imports: [RouterLink, LangRoutePipe, TranslatePipe],
|
imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe],
|
||||||
templateUrl: './subcategories.component.html',
|
templateUrl: './subcategories.component.html',
|
||||||
styleUrls: ['./subcategories.component.scss'],
|
styleUrls: ['./subcategories.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -17,6 +19,10 @@ import { TranslateService } from '../../i18n/translate.service';
|
|||||||
export class SubcategoriesComponent implements OnInit, OnDestroy {
|
export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||||
categories = signal<Category[]>([]);
|
categories = signal<Category[]>([]);
|
||||||
subcategories = signal<Category[]>([]);
|
subcategories = signal<Category[]>([]);
|
||||||
|
/** Nested subcategories from API with hasItems support */
|
||||||
|
nestedSubcategories = signal<Subcategory[]>([]);
|
||||||
|
/** Items belonging directly to this category (when hasItems is true) */
|
||||||
|
categoryItems = signal<Item[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
@@ -29,7 +35,8 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private langService: LanguageService
|
private langService: LanguageService,
|
||||||
|
private cartService: CartService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -45,19 +52,40 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private loadForParent(parentID: number): void {
|
private loadForParent(parentID: number): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.categoryItems.set([]);
|
||||||
|
this.nestedSubcategories.set([]);
|
||||||
|
|
||||||
this.apiService.getCategories().subscribe({
|
this.apiService.getCategories().subscribe({
|
||||||
next: (cats) => {
|
next: (cats) => {
|
||||||
this.categories.set(cats);
|
this.categories.set(cats);
|
||||||
const subs = cats.filter(c => c.parentID === parentID);
|
|
||||||
const parent = cats.find(c => c.categoryID === parentID);
|
const parent = cats.find(c => c.categoryID === parentID);
|
||||||
this.parentName.set(parent ? parent.name : this.i18n.t('home.categoriesTitle'));
|
this.parentName.set(parent ? parent.name : this.i18n.t('home.categoriesTitle'));
|
||||||
|
|
||||||
if (!subs || subs.length === 0) {
|
// Check for nested subcategories from API response (backOffice format)
|
||||||
|
const nested = parent?.subcategories || [];
|
||||||
|
const visibleNested = nested.filter(s => s.visible !== false);
|
||||||
|
|
||||||
|
// Also check flat legacy subcategories
|
||||||
|
const flatSubs = cats.filter(c => c.parentID === parentID);
|
||||||
|
|
||||||
|
if (visibleNested.length > 0) {
|
||||||
|
// Use nested subcategories from API
|
||||||
|
this.nestedSubcategories.set(visibleNested);
|
||||||
|
this.subcategories.set([]);
|
||||||
|
|
||||||
|
// If this category itself has items, load them too
|
||||||
|
this.loadCategoryItems(parentID);
|
||||||
|
} else if (flatSubs.length > 0) {
|
||||||
|
// Legacy flat subcategories
|
||||||
|
this.subcategories.set(flatSubs);
|
||||||
|
this.nestedSubcategories.set([]);
|
||||||
|
|
||||||
|
// Also load items for this category in case it has direct items
|
||||||
|
this.loadCategoryItems(parentID);
|
||||||
|
} else {
|
||||||
// No subcategories: redirect to items list for this category
|
// No subcategories: redirect to items list for this category
|
||||||
const lang = this.langService.currentLanguage();
|
const lang = this.langService.currentLanguage();
|
||||||
this.router.navigate([`/${lang}/category`, parentID, 'items'], { replaceUrl: true });
|
this.router.navigate([`/${lang}/category`, parentID, 'items'], { replaceUrl: true });
|
||||||
} else {
|
|
||||||
this.subcategories.set(subs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
@@ -70,8 +98,41 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Load items that belong directly to this category */
|
||||||
|
private loadCategoryItems(categoryID: number): void {
|
||||||
|
this.apiService.getCategoryItems(categoryID, 50, 0).subscribe({
|
||||||
|
next: (items) => {
|
||||||
|
this.categoryItems.set(items);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
// Not critical — subcategories still work
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSubcategories(): boolean {
|
||||||
|
return this.subcategories().length > 0 || this.nestedSubcategories().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToCart(itemID: number, event: Event): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.cartService.addItem(itemID);
|
||||||
|
}
|
||||||
|
|
||||||
// TrackBy function for performance optimization
|
// TrackBy function for performance optimization
|
||||||
trackByCategoryId(index: number, category: Category): number {
|
trackByCategoryId(index: number, category: Category): number {
|
||||||
return category.categoryID;
|
return category.categoryID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackBySubId(index: number, sub: Subcategory): string {
|
||||||
|
return sub.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly getDiscountedPrice = getDiscountedPrice;
|
||||||
|
readonly getMainImage = getMainImage;
|
||||||
|
readonly trackByItemId = trackByItemId;
|
||||||
|
readonly getBadgeClass = getBadgeClass;
|
||||||
|
|
||||||
|
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Frequently Asked Questions (FAQ) 📌</h1>
|
<h1>Frequently Asked Questions (FAQ) 📌</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -238,3 +240,5 @@
|
|||||||
<h2>Need Help?</h2>
|
<h2>Need Help?</h2>
|
||||||
<p>If you have any additional questions, please contact us at <a href="mailto:info@dexarmarket.ru">info@dexarmarket.ru</a> — we will promptly resolve any of your questions!</p>
|
<p>If you have any additional questions, please contact us at <a href="mailto:info@dexarmarket.ru">info@dexarmarket.ru</a> — we will promptly resolve any of your questions!</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Հաճախ տրվող հարցեր (FAQ) 📌</h1>
|
<h1>Հաճախ տրվող հարցեր (FAQ) 📌</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -238,3 +240,5 @@
|
|||||||
<h2>Օգնություն է հարկավոր։</h2>
|
<h2>Օգնություն է հարկավոր։</h2>
|
||||||
<p>Եթե լրացուցիչ հարցեր ունեք, դիմեք <a href="mailto:info@dexarmarket.ru">info@dexarmarket.ru</a> — մենք արագորեն կլուծենք ձեր հարցերը։</p>
|
<p>Եթե լրացուցիչ հարցեր ունեք, դիմեք <a href="mailto:info@dexarmarket.ru">info@dexarmarket.ru</a> — մենք արագորեն կլուծենք ձեր հարցերը։</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Часто задаваемые вопросы (FAQ) 📌</h1>
|
<h1>Часто задаваемые вопросы (FAQ) 📌</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -238,3 +240,5 @@
|
|||||||
<h2>Нужна помощь?</h2>
|
<h2>Нужна помощь?</h2>
|
||||||
<p>Если возникнут дополнительные вопросы, обращайтесь на <a href="mailto:info@dexarmarket.ru">info@dexarmarket.ru</a> — мы оперативно решим любые ваши вопросы!</p>
|
<p>Если возникнут дополнительные вопросы, обращайтесь на <a href="mailto:info@dexarmarket.ru">info@dexarmarket.ru</a> — мы оперативно решим любые ваши вопросы!</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -55,7 +55,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="novo-info">
|
<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">
|
<div class="novo-rating">
|
||||||
<span class="stars">{{ getRatingStars(item()!.rating) }}</span>
|
<span class="stars">{{ getRatingStars(item()!.rating) }}</span>
|
||||||
@@ -77,12 +93,43 @@
|
|||||||
|
|
||||||
<div class="novo-stock">
|
<div class="novo-stock">
|
||||||
<span class="stock-label">{{ 'itemDetail.stock' | translate }}</span>
|
<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>
|
<span class="dot"></span>
|
||||||
{{ item()!.remainings === 'high' ? ('itemDetail.inStock' | translate) : item()!.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lowStock' | translate) }}
|
{{ getStockLabel() }}
|
||||||
</div>
|
</div>
|
||||||
|
@if (item()!.quantity != null) {
|
||||||
|
<span class="stock-qty">({{ item()!.quantity }} шт.)</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (item()!.colour || item()!.size) {
|
||||||
|
<div class="novo-variants">
|
||||||
|
@if (item()!.colour) {
|
||||||
|
<div class="variant-group">
|
||||||
|
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
|
||||||
|
<span class="variant-chip colour-chip">{{ item()!.colour }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (item()!.size) {
|
||||||
|
<div class="variant-group">
|
||||||
|
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
|
||||||
|
<span class="variant-chip size-chip">{{ item()!.size }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item()!.attributes && item()!.attributes!.length > 0) {
|
||||||
|
<div class="novo-attributes">
|
||||||
|
@for (attr of item()!.attributes!; track attr.key) {
|
||||||
|
<div class="attribute-row">
|
||||||
|
<span class="attribute-key">{{ attr.key }}</span>
|
||||||
|
<span class="attribute-value">{{ attr.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<button class="novo-add-cart" (click)="addToCart()">
|
<button class="novo-add-cart" (click)="addToCart()">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="9" cy="21" r="1"></circle>
|
<circle cx="9" cy="21" r="1"></circle>
|
||||||
@@ -93,8 +140,26 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="novo-description">
|
<div class="novo-description">
|
||||||
|
@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>
|
<h3>{{ 'itemDetail.description' | translate }}</h3>
|
||||||
<div [innerHTML]="getSafeHtml(item()!.description)"></div>
|
<div [innerHTML]="getSafeHtml(item()!.description)"></div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +314,23 @@
|
|||||||
|
|
||||||
<!-- Item Info -->
|
<!-- Item Info -->
|
||||||
<div class="dx-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-rating">
|
||||||
<div class="dx-stars">
|
<div class="dx-stars">
|
||||||
@@ -277,15 +358,43 @@
|
|||||||
|
|
||||||
<div class="dx-stock">
|
<div class="dx-stock">
|
||||||
<span class="dx-stock-label">{{ 'itemDetail.stock' | translate }}</span>
|
<span class="dx-stock-label">{{ 'itemDetail.stock' | translate }}</span>
|
||||||
<span class="dx-stock-status"
|
<span class="dx-stock-status" [class]="getStockClass()">
|
||||||
[class.high]="item()!.remainings === 'high'"
|
|
||||||
[class.medium]="item()!.remainings === 'medium'"
|
|
||||||
[class.low]="item()!.remainings === 'low'">
|
|
||||||
<span class="dx-stock-dot"></span>
|
<span class="dx-stock-dot"></span>
|
||||||
{{ item()!.remainings === 'high' ? ('itemDetail.inStock' | translate) : item()!.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lastItems' | translate) }}
|
{{ getStockLabel() }}
|
||||||
</span>
|
</span>
|
||||||
|
@if (item()!.quantity != null) {
|
||||||
|
<span class="dx-stock-qty">({{ item()!.quantity }} шт.)</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (item()!.colour || item()!.size) {
|
||||||
|
<div class="dx-variants">
|
||||||
|
@if (item()!.colour) {
|
||||||
|
<div class="variant-group">
|
||||||
|
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
|
||||||
|
<span class="variant-chip colour-chip">{{ item()!.colour }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (item()!.size) {
|
||||||
|
<div class="variant-group">
|
||||||
|
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
|
||||||
|
<span class="variant-chip size-chip">{{ item()!.size }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item()!.attributes && item()!.attributes!.length > 0) {
|
||||||
|
<div class="dx-attributes">
|
||||||
|
@for (attr of item()!.attributes!; track attr.key) {
|
||||||
|
<div class="attribute-row">
|
||||||
|
<span class="attribute-key">{{ attr.key }}</span>
|
||||||
|
<span class="attribute-value">{{ attr.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<button class="dx-add-cart" (click)="addToCart()">
|
<button class="dx-add-cart" (click)="addToCart()">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="9" cy="21" r="1"></circle>
|
<circle cx="9" cy="21" r="1"></circle>
|
||||||
@@ -296,8 +405,26 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dx-description">
|
<div class="dx-description">
|
||||||
|
@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>
|
<h2>{{ 'itemDetail.description' | translate }}</h2>
|
||||||
<div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div>
|
<div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -291,6 +291,64 @@ $dx-card-bg: #f5f3f9;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variant chips (colour/size) — shared between dexar and novo
|
||||||
|
.dx-variants, .novo-variants {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.variant-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1.5px solid $dx-border;
|
||||||
|
background: rgba(73, 118, 113, 0.06);
|
||||||
|
color: $dx-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-attributes, .novo-attributes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #f8fafa;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
.attribute-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute-key {
|
||||||
|
color: #6b7280;
|
||||||
|
&::after { content: ':'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dx-description {
|
.dx-description {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid $dx-border;
|
border-top: 1px solid $dx-border;
|
||||||
@@ -642,22 +700,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) {
|
@media (max-width: 992px) {
|
||||||
.dx-item-content {
|
.dx-item-content {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 32px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.dx-item-container {
|
.dx-item-container {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On mobile: thumbnails go below main photo
|
.dx-item-content {
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnails go below main photo
|
||||||
.dx-gallery {
|
.dx-gallery {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dx-thumbnails {
|
.dx-thumbnails {
|
||||||
@@ -666,14 +772,16 @@ $dx-card-bg: #f5f3f9;
|
|||||||
max-height: none;
|
max-height: none;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
order: 1; // put below main photo
|
order: 1;
|
||||||
|
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
&::-webkit-scrollbar { display: none; }
|
&::-webkit-scrollbar { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.dx-main-photo {
|
.dx-main-photo {
|
||||||
order: 0; // main photo first
|
order: 0;
|
||||||
|
max-height: 400px;
|
||||||
|
aspect-ratio: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dx-thumb {
|
.dx-thumb {
|
||||||
@@ -690,8 +798,32 @@ $dx-card-bg: #f5f3f9;
|
|||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dx-old-price {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dx-add-cart {
|
.dx-add-cart {
|
||||||
max-width: 100%;
|
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 {
|
.dx-review-form {
|
||||||
@@ -707,21 +839,153 @@ $dx-card-bg: #f5f3f9;
|
|||||||
width: 100%;
|
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) {
|
@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 {
|
.dx-thumb {
|
||||||
width: 56px;
|
width: 52px;
|
||||||
height: 56px;
|
height: 52px;
|
||||||
min-width: 56px;
|
min-width: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dx-title {
|
.dx-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-info {
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dx-current-price {
|
.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 +1563,20 @@ $dx-card-bg: #f5f3f9;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== NOVO RESPONSIVE ==========
|
||||||
|
|
||||||
|
// Tablet portrait
|
||||||
@media (max-width: 968px) {
|
@media (max-width: 968px) {
|
||||||
.novo-item-content {
|
.novo-item-content {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.novo-gallery {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.novo-info .novo-title {
|
.novo-info .novo-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -1313,6 +1585,10 @@ $dx-card-bg: #f5f3f9;
|
|||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.novo-info .novo-add-cart {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.novo-review-form {
|
.novo-review-form {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|
||||||
@@ -1327,3 +1603,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 { DecimalPipe } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { ApiService, CartService, TelegramService, SeoService } from '../../services';
|
import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services';
|
||||||
import { Item } from '../../models';
|
import { Item, DescriptionField } from '../../models';
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { SecurityContext } from '@angular/core';
|
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 { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../i18n/translate.service';
|
import { TranslateService } from '../../i18n/translate.service';
|
||||||
@@ -48,7 +48,8 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
|||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private cartService: CartService,
|
private cartService: CartService,
|
||||||
private telegramService: TelegramService,
|
private telegramService: TelegramService,
|
||||||
private sanitizer: DomSanitizer
|
private sanitizer: DomSanitizer,
|
||||||
|
private languageService: LanguageService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -100,6 +101,57 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
|||||||
return getDiscountedPrice(currentItem);
|
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 {
|
getSafeHtml(html: string): SafeHtml {
|
||||||
return this.sanitizer.sanitize(SecurityContext.HTML, html) || '';
|
return this.sanitizer.sanitize(SecurityContext.HTML, html) || '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Company Details</h1>
|
<h1>Company Details</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -96,3 +98,5 @@
|
|||||||
<p><strong>General Director:</strong> Hovhannisyan Ashot Rafikovich</p>
|
<p><strong>General Director:</strong> Hovhannisyan Ashot Rafikovich</p>
|
||||||
<p><strong>Basis of authority:</strong> Charter</p>
|
<p><strong>Basis of authority:</strong> Charter</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Կազմակերպության տվյալներ</h1>
|
<h1>Կազմակերպության տվյալներ</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -96,3 +98,5 @@
|
|||||||
<p><strong>Գլխավոր տնօրեն՝</strong> Օհաննիսյան Աշոտ Ռաֆիկի</p>
|
<p><strong>Գլխավոր տնօրեն՝</strong> Օհաննիսյան Աշոտ Ռաֆիկի</p>
|
||||||
<p><strong>Գործողության հիմք՝</strong> Կանոնադրություն</p>
|
<p><strong>Գործողության հիմք՝</strong> Կանոնադրություն</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Реквизиты организации</h1>
|
<h1>Реквизиты организации</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -96,3 +98,5 @@
|
|||||||
<p><strong>Генеральный директор:</strong> Оганнисян Ашот Рафикович</p>
|
<p><strong>Генеральный директор:</strong> Оганнисян Ашот Рафикович</p>
|
||||||
<p><strong>Основание действий:</strong> Устав</p>
|
<p><strong>Основание действий:</strong> Устав</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Payment Terms</h1>
|
<h1>Payment Terms</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -111,3 +113,5 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p>When contacting us, please provide your order number and a brief description of the issue for a faster resolution.</p>
|
<p>When contacting us, please provide your order number and a brief description of the issue for a faster resolution.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Վճարման կանոններ</h1>
|
<h1>Վճարման կանոններ</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -111,3 +113,5 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Դիմելիս նշեք պատվերի համարը և խնդրի հակիրճ նկարագրությունը՝ հարցի ավելի արագ լուծման համար։</p>
|
<p>Դիմելիս նշեք պատվերի համարը և խնդրի հակիրճ նկարագրությունը՝ հարցի ավելի արագ լուծման համար։</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Правила оплаты</h1>
|
<h1>Правила оплаты</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -111,3 +113,5 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p>При обращении указывайте номер заказа и краткое описание проблемы для более быстрого решения вопроса.</p>
|
<p>При обращении указывайте номер заказа и краткое описание проблемы для более быстрого решения вопроса.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>PERSONAL DATA PROCESSING POLICY</h1>
|
<h1>PERSONAL DATA PROCESSING POLICY</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -361,3 +363,5 @@
|
|||||||
|
|
||||||
<p>12.3. If the Operator can reasonably associate the information specified in this section with the personal account of a specific User, then such information may be processed together with the PD and other personal information of such User.</p>
|
<p>12.3. If the Operator can reasonably associate the information specified in this section with the personal account of a specific User, then such information may be processed together with the PD and other personal information of such User.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>PERSONAL DATA PROCESSING POLICY</h1>
|
<h1>PERSONAL DATA PROCESSING POLICY</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -361,3 +363,5 @@
|
|||||||
|
|
||||||
<p>12.3. If the Operator can reasonably associate the information specified in this section with the personal account of a specific User, then such information may be processed together with the PD and other personal information of such User.</p>
|
<p>12.3. If the Operator can reasonably associate the information specified in this section with the personal account of a specific User, then such information may be processed together with the PD and other personal information of such User.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>ПОЛИТИКА В ОТНОШЕНИИ ОБРАБОТКИ ПЕРСОНАЛЬНЫХ ДАННЫХ</h1>
|
<h1>ПОЛИТИКА В ОТНОШЕНИИ ОБРАБОТКИ ПЕРСОНАЛЬНЫХ ДАННЫХ</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -361,3 +363,5 @@
|
|||||||
|
|
||||||
<p>12.3. Если Оператор может разумно соотнести указанные в настоящем разделе сведения с личным кабинетом конкретного Пользователя, то такие сведения могут обрабатываться совместно с ПДн и иной личной информацией такого Пользователя.</p>
|
<p>12.3. Если Оператор может разумно соотнести указанные в настоящем разделе сведения с личным кабинетом конкретного Пользователя, то такие сведения могут обрабатываться совместно с ПДн и иной личной информацией такого Пользователя.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>PUBLIC OFFER AGREEMENT</h1>
|
<h1>PUBLIC OFFER AGREEMENT</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -459,3 +461,5 @@
|
|||||||
<p><strong>16.8. Response to Violations</strong></p>
|
<p><strong>16.8. Response to Violations</strong></p>
|
||||||
<p>Non-intervention by the Site Owner in the event of violations of agreements by Users does not prevent subsequent measures to protect the Owner's interests at a later date.</p>
|
<p>Non-intervention by the Site Owner in the event of violations of agreements by Users does not prevent subsequent measures to protect the Owner's interests at a later date.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
<h1>Հdelays DELAYS ՀԱՄDELAYS</h1>
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
|
<h1>Հdelays DELAYS ՀԱՄDELAYS</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>СОГЛАШЕНИЕ ПУБЛИЧНОЙ ОФЕРТЫ</h1>
|
<h1>СОГЛАШЕНИЕ ПУБЛИЧНОЙ ОФЕРТЫ</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -459,3 +461,5 @@
|
|||||||
<p><strong>16.8. Реакция на нарушения</strong></p>
|
<p><strong>16.8. Реакция на нарушения</strong></p>
|
||||||
<p>Невмешательство Владельца сайта в случае нарушений соглашений Пользователями не препятствует последующим мерам защиты интересов Владельца позже.</p>
|
<p>Невмешательство Владельца сайта в случае нарушений соглашений Пользователями не препятствует последующим мерам защиты интересов Владельца позже.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Return Policy</h1>
|
<h1>Return Policy</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -128,3 +130,5 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p>If the conflict cannot be resolved amicably, the Buyer has the right to file a complaint with Rospotrebnadzor or the court at the Seller's location.</p>
|
<p>If the conflict cannot be resolved amicably, the Buyer has the right to file a complaint with Rospotrebnadzor or the court at the Seller's location.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Ապրանքների վերադարձի քաղաքականություն</h1>
|
<h1>Ապրանքների վերադարձի քաղաքականություն</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -128,3 +130,5 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Եթե կոնֆլիկտը հնարավոր չէ լուծել խաղաղ ճանապարհով՝ Գնորդը իրավունք ունի բողոք ներկայացնելու Ռոսպոտրեբնաձոր կամ դատարան Վաճառողի գտնվելու վայրում։</p>
|
<p>Եթե կոնֆլիկտը հնարավոր չէ լուծել խաղաղ ճանապարհով՝ Գնորդը իրավունք ունի բողոք ներկայացնելու Ռոսպոտրեբնաձոր կամ դատարան Վաճառողի գտնվելու վայրում։</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<div class="legal-page">
|
||||||
|
<div class="legal-container">
|
||||||
<h1>Политика возврата товаров</h1>
|
<h1>Политика возврата товаров</h1>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
@@ -128,3 +130,5 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Если конфликт невозможно разрешить мирно, Покупатель вправе подать жалобу в Роспотребнадзор или суд по месту расположения Продавца.</p>
|
<p>Если конфликт невозможно разрешить мирно, Покупатель вправе подать жалобу в Роспотребнадзор или суд по месту расположения Продавца.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -59,14 +59,25 @@
|
|||||||
<div class="item-card">
|
<div class="item-card">
|
||||||
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
||||||
<div class="item-image">
|
<div class="item-image">
|
||||||
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" width="300" height="300" />
|
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
|
||||||
@if (item.discount > 0) {
|
@if (item.discount > 0) {
|
||||||
<div class="discount-badge">-{{ item.discount }}%</div>
|
<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>
|
||||||
|
|
||||||
<div class="item-details">
|
<div class="item-details">
|
||||||
<h3 class="item-name">{{ item.name }}</h3>
|
<h3 class="item-name">{{ itemName(item) }}</h3>
|
||||||
|
|
||||||
|
@if (itemDesc(item)) {
|
||||||
|
<p class="item-simple-desc">{{ itemDesc(item) }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="item-rating">
|
<div class="item-rating">
|
||||||
<span class="rating-stars">⭐ {{ item.rating }}</span>
|
<span class="rating-stars">⭐ {{ item.rating }}</span>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { ApiService, CartService } from '../../services';
|
|||||||
import { Item } from '../../models';
|
import { Item } from '../../models';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||||
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
|
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||||
|
import { LanguageService } from '../../services/language.service';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../i18n/translate.service';
|
import { TranslateService } from '../../i18n/translate.service';
|
||||||
@@ -27,7 +28,7 @@ export class SearchComponent implements OnDestroy {
|
|||||||
totalResults = signal<number>(0);
|
totalResults = signal<number>(0);
|
||||||
|
|
||||||
private skip = 0;
|
private skip = 0;
|
||||||
private readonly count = 20;
|
private readonly count = 50;
|
||||||
private isLoadingMore = false;
|
private isLoadingMore = false;
|
||||||
private searchSubject = new Subject<string>();
|
private searchSubject = new Subject<string>();
|
||||||
private searchSubscription: Subscription;
|
private searchSubscription: Subscription;
|
||||||
@@ -136,4 +137,9 @@ export class SearchComponent implements OnDestroy {
|
|||||||
readonly getDiscountedPrice = getDiscountedPrice;
|
readonly getDiscountedPrice = getDiscountedPrice;
|
||||||
readonly getMainImage = getMainImage;
|
readonly getMainImage = getMainImage;
|
||||||
readonly trackByItemId = trackByItemId;
|
readonly trackByItemId = trackByItemId;
|
||||||
|
readonly getBadgeClass = getBadgeClass;
|
||||||
|
|
||||||
|
private langService = inject(LanguageService);
|
||||||
|
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
||||||
|
itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { Category, Item } from '../models';
|
import { Category, Item, Subcategory } from '../models';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -13,38 +13,174 @@ export class ApiService {
|
|||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
private normalizeItem(item: Item): Item {
|
/**
|
||||||
return {
|
* Normalize an item from the API response — supports both
|
||||||
...item,
|
* legacy marketplace format and the new backOffice API format.
|
||||||
remainings: item.remainings || 'high'
|
*/
|
||||||
};
|
private normalizeItem(raw: any): Item {
|
||||||
|
const { partnerID, ...rest } = raw;
|
||||||
|
const item: Item = { ...rest };
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeItems(items: Item[] | null | undefined): Item[] {
|
// Map backOffice imgs[] → legacy photos[]
|
||||||
|
if (raw.imgs && (!raw.photos || raw.photos.length === 0)) {
|
||||||
|
item.photos = raw.imgs.map((url: string) => ({ url }));
|
||||||
|
}
|
||||||
|
// Normalize photo type: API sends type='video'|'photo', template checks .video
|
||||||
|
if (item.photos) {
|
||||||
|
item.photos = item.photos.map((p: any) => ({
|
||||||
|
...p,
|
||||||
|
video: p.video || (p.type === 'video' ? p.url : undefined),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
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 backend names[] → translations (multi-lang name support)
|
||||||
|
if (raw.names && Array.isArray(raw.names)) {
|
||||||
|
item.names = raw.names;
|
||||||
|
if (!item.translations) item.translations = {};
|
||||||
|
for (const entry of raw.names) {
|
||||||
|
if (!item.translations[entry.language]) item.translations[entry.language] = {};
|
||||||
|
item.translations[entry.language].name = entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map backend descriptions[] → translations (multi-lang descriptions)
|
||||||
|
if (raw.descriptions && Array.isArray(raw.descriptions)) {
|
||||||
|
item.descriptions = raw.descriptions;
|
||||||
|
if (!item.translations) item.translations = {};
|
||||||
|
for (const entry of raw.descriptions) {
|
||||||
|
if (!item.translations[entry.language]) item.translations[entry.language] = {};
|
||||||
|
item.translations[entry.language].simpleDescription = entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve attributes from backend
|
||||||
|
item.attributes = raw.attributes || [];
|
||||||
|
|
||||||
|
// Preserve colour & size
|
||||||
|
item.colour = raw.colour || '';
|
||||||
|
item.size = raw.size || '';
|
||||||
|
|
||||||
|
// 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 = item.translations || raw.translations || {};
|
||||||
|
item.visible = raw.visible ?? true;
|
||||||
|
item.priority = raw.priority ?? 0;
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeItems(items: any[] | null | undefined): Item[] {
|
||||||
if (!items || !Array.isArray(items)) {
|
if (!items || !Array.isArray(items)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return items.map(item => this.normalizeItem(item));
|
return items.map(item => this.normalizeItem(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }> {
|
ping(): Observable<{ message: string }> {
|
||||||
return this.http.get<{ message: string }>(`${this.baseUrl}/ping`);
|
return this.http.get<{ message: string }>(`${this.baseUrl}/ping`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategories(): Observable<Category[]> {
|
getCategories(): Observable<Category[]> {
|
||||||
return this.http.get<Category[]>(`${this.baseUrl}/category`);
|
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[]> {
|
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
|
||||||
const params = new HttpParams()
|
const params = new HttpParams()
|
||||||
.set('count', count.toString())
|
.set('count', count.toString())
|
||||||
.set('skip', skip.toString());
|
.set('skip', skip.toString());
|
||||||
return this.http.get<Item[]>(`${this.baseUrl}/category/${categoryID}`, { params })
|
return this.http.get<any[]>(`${this.baseUrl}/category/${categoryID}`, { params })
|
||||||
.pipe(map(items => this.normalizeItems(items)));
|
.pipe(map(items => this.normalizeItems(items)));
|
||||||
}
|
}
|
||||||
|
|
||||||
getItem(itemID: number): Observable<Item> {
|
getItem(itemID: number): Observable<Item> {
|
||||||
return this.http.get<Item>(`${this.baseUrl}/item/${itemID}`)
|
return this.http.get<any>(`${this.baseUrl}/item/${itemID}`)
|
||||||
.pipe(map(item => this.normalizeItem(item)));
|
.pipe(map(item => this.normalizeItem(item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +189,7 @@ export class ApiService {
|
|||||||
.set('search', search)
|
.set('search', search)
|
||||||
.set('count', count.toString())
|
.set('count', count.toString())
|
||||||
.set('skip', skip.toString());
|
.set('skip', skip.toString());
|
||||||
return this.http.get<{ items: Item[], total: number, count: number, skip: number }>(`${this.baseUrl}/searchitems`, { params })
|
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
|
||||||
.pipe(
|
.pipe(
|
||||||
map(response => ({
|
map(response => ({
|
||||||
items: this.normalizeItems(response?.items || []),
|
items: this.normalizeItems(response?.items || []),
|
||||||
@@ -75,7 +211,7 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getCart(): Observable<Item[]> {
|
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)));
|
.pipe(map(items => this.normalizeItems(items)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +297,7 @@ export class ApiService {
|
|||||||
if (categoryID) {
|
if (categoryID) {
|
||||||
params = params.set('category', categoryID.toString());
|
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)));
|
.pipe(map(items => this.normalizeItems(items)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
src/app/services/auth.service.ts
Normal file
128
src/app/services/auth.service.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, of, catchError, map, tap } from 'rxjs';
|
||||||
|
import { AuthSession, AuthStatus } from '../models/auth.model';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
private sessionSignal = signal<AuthSession | null>(null);
|
||||||
|
private statusSignal = signal<AuthStatus>('unknown');
|
||||||
|
private showLoginSignal = signal(false);
|
||||||
|
|
||||||
|
/** Current auth session */
|
||||||
|
readonly session = this.sessionSignal.asReadonly();
|
||||||
|
/** Current auth status */
|
||||||
|
readonly status = this.statusSignal.asReadonly();
|
||||||
|
/** Whether user is fully authenticated */
|
||||||
|
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
||||||
|
/** Whether to show login dialog */
|
||||||
|
readonly showLoginDialog = this.showLoginSignal.asReadonly();
|
||||||
|
/** Display name of authenticated user */
|
||||||
|
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
||||||
|
|
||||||
|
private readonly apiUrl = environment.apiUrl;
|
||||||
|
private sessionCheckTimer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {
|
||||||
|
// On init, check existing session via cookie
|
||||||
|
this.checkSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check current session status with backend.
|
||||||
|
* The backend reads the session cookie and returns the session info.
|
||||||
|
*/
|
||||||
|
checkSession(): void {
|
||||||
|
this.statusSignal.set('checking');
|
||||||
|
|
||||||
|
this.http.get<AuthSession>(`${this.apiUrl}/auth/session`, {
|
||||||
|
withCredentials: true
|
||||||
|
}).pipe(
|
||||||
|
catchError(() => {
|
||||||
|
this.statusSignal.set('unauthenticated');
|
||||||
|
this.sessionSignal.set(null);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
).subscribe(session => {
|
||||||
|
if (session && session.active) {
|
||||||
|
this.sessionSignal.set(session);
|
||||||
|
this.statusSignal.set('authenticated');
|
||||||
|
this.scheduleSessionRefresh(session.expiresAt);
|
||||||
|
} else if (session && !session.active) {
|
||||||
|
this.sessionSignal.set(null);
|
||||||
|
this.statusSignal.set('expired');
|
||||||
|
} else {
|
||||||
|
this.statusSignal.set('unauthenticated');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after user completes Telegram login.
|
||||||
|
* The callback URL from Telegram will hit our backend which sets the cookie.
|
||||||
|
* Then we re-check the session.
|
||||||
|
*/
|
||||||
|
onTelegramLoginComplete(): void {
|
||||||
|
this.checkSession();
|
||||||
|
this.hideLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate the Telegram login URL for bot-based auth */
|
||||||
|
getTelegramLoginUrl(): string {
|
||||||
|
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'dexarmarket_bot';
|
||||||
|
const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`);
|
||||||
|
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get QR code data URL for Telegram login */
|
||||||
|
getTelegramQrUrl(): string {
|
||||||
|
return this.getTelegramLoginUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show login dialog (called when user tries to pay without being logged in) */
|
||||||
|
requestLogin(): void {
|
||||||
|
this.showLoginSignal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide login dialog */
|
||||||
|
hideLogin(): void {
|
||||||
|
this.showLoginSignal.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logout — clears session on backend and locally */
|
||||||
|
logout(): void {
|
||||||
|
this.http.post(`${this.apiUrl}/auth/logout`, {}, {
|
||||||
|
withCredentials: true
|
||||||
|
}).pipe(
|
||||||
|
catchError(() => of(null))
|
||||||
|
).subscribe(() => {
|
||||||
|
this.sessionSignal.set(null);
|
||||||
|
this.statusSignal.set('unauthenticated');
|
||||||
|
this.clearSessionRefresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedule a session re-check before it expires */
|
||||||
|
private scheduleSessionRefresh(expiresAt: string): void {
|
||||||
|
this.clearSessionRefresh();
|
||||||
|
|
||||||
|
const expiresMs = new Date(expiresAt).getTime();
|
||||||
|
const nowMs = Date.now();
|
||||||
|
// Re-check 60 seconds before expiry, minimum 30s from now
|
||||||
|
const refreshIn = Math.max(expiresMs - nowMs - 60_000, 30_000);
|
||||||
|
|
||||||
|
this.sessionCheckTimer = setTimeout(() => {
|
||||||
|
this.checkSession();
|
||||||
|
}, refreshIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSessionRefresh(): void {
|
||||||
|
if (this.sessionCheckTimer) {
|
||||||
|
clearTimeout(this.sessionCheckTimer);
|
||||||
|
this.sessionCheckTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,5 @@ export * from './cart.service';
|
|||||||
export * from './telegram.service';
|
export * from './telegram.service';
|
||||||
export * from './language.service';
|
export * from './language.service';
|
||||||
export * from './seo.service';
|
export * from './seo.service';
|
||||||
|
export * from './location.service';
|
||||||
|
export * from './auth.service';
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ export interface Language {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Currency {
|
||||||
|
code: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class LanguageService {
|
export class LanguageService {
|
||||||
private currentLanguageSignal = signal<string>('ru');
|
private currentLanguageSignal = signal<string>('ru');
|
||||||
|
private currentCurrencySignal = signal<string>('RUB');
|
||||||
|
|
||||||
languages: Language[] = [
|
languages: Language[] = [
|
||||||
{ code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true },
|
{ code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true },
|
||||||
@@ -21,7 +28,15 @@ export class LanguageService {
|
|||||||
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: true }
|
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
currencies: Currency[] = [
|
||||||
|
{ code: 'RUB', symbol: '₽', name: 'Рубль' },
|
||||||
|
{ code: 'USD', symbol: '$', name: 'Dollar' },
|
||||||
|
{ code: 'EUR', symbol: '€', name: 'Euro' },
|
||||||
|
{ code: 'AMD', symbol: '֏', name: 'Դրամ' },
|
||||||
|
];
|
||||||
|
|
||||||
currentLanguage = this.currentLanguageSignal.asReadonly();
|
currentLanguage = this.currentLanguageSignal.asReadonly();
|
||||||
|
currentCurrency = this.currentCurrencySignal.asReadonly();
|
||||||
|
|
||||||
constructor(private router: Router) {
|
constructor(private router: Router) {
|
||||||
// Load saved language from localStorage
|
// Load saved language from localStorage
|
||||||
@@ -29,6 +44,11 @@ export class LanguageService {
|
|||||||
if (savedLang && this.languages.find(l => l.code === savedLang && l.enabled)) {
|
if (savedLang && this.languages.find(l => l.code === savedLang && l.enabled)) {
|
||||||
this.currentLanguageSignal.set(savedLang);
|
this.currentLanguageSignal.set(savedLang);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedCurrency = localStorage.getItem('selectedCurrency');
|
||||||
|
if (savedCurrency && this.currencies.find(c => c.code === savedCurrency)) {
|
||||||
|
this.currentCurrencySignal.set(savedCurrency);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLanguage(langCode: string): void {
|
setLanguage(langCode: string): void {
|
||||||
@@ -39,6 +59,18 @@ export class LanguageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCurrency(code: string): void {
|
||||||
|
const currency = this.currencies.find(c => c.code === code);
|
||||||
|
if (currency) {
|
||||||
|
this.currentCurrencySignal.set(code);
|
||||||
|
localStorage.setItem('selectedCurrency', code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentCurrency(): Currency | undefined {
|
||||||
|
return this.currencies.find(c => c.code === this.currentCurrencySignal());
|
||||||
|
}
|
||||||
|
|
||||||
/** Change language and navigate to the same page with the new prefix */
|
/** Change language and navigate to the same page with the new prefix */
|
||||||
switchLanguage(langCode: string): void {
|
switchLanguage(langCode: string): void {
|
||||||
const lang = this.languages.find(l => l.code === langCode);
|
const lang = this.languages.find(l => l.code === langCode);
|
||||||
|
|||||||
135
src/app/services/location.service.ts
Normal file
135
src/app/services/location.service.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Region, GeoIpResponse } from '../models/location.model';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'selected_region';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class LocationService {
|
||||||
|
private regionSignal = signal<Region | null>(null);
|
||||||
|
private regionsSignal = signal<Region[]>([]);
|
||||||
|
private loadingSignal = signal(false);
|
||||||
|
private detectedSignal = signal(false);
|
||||||
|
|
||||||
|
/** Current selected region (null = global / all regions) */
|
||||||
|
readonly region = this.regionSignal.asReadonly();
|
||||||
|
/** All available regions */
|
||||||
|
readonly regions = this.regionsSignal.asReadonly();
|
||||||
|
/** Whether geo-detection is in progress */
|
||||||
|
readonly detecting = this.loadingSignal.asReadonly();
|
||||||
|
/** Whether region was auto-detected */
|
||||||
|
readonly autoDetected = this.detectedSignal.asReadonly();
|
||||||
|
|
||||||
|
/** Computed region id for API calls — empty string means global */
|
||||||
|
readonly regionId = computed(() => this.regionSignal()?.id ?? '');
|
||||||
|
|
||||||
|
private readonly apiUrl = environment.apiUrl;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {
|
||||||
|
this.loadRegions();
|
||||||
|
this.restoreFromStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch available regions from backend */
|
||||||
|
loadRegions(): void {
|
||||||
|
this.http.get<Region[]>(`${this.apiUrl}/regions`).subscribe({
|
||||||
|
next: (regions) => {
|
||||||
|
this.regionsSignal.set(regions);
|
||||||
|
// If we have a stored region, validate it still exists
|
||||||
|
const stored = this.regionSignal();
|
||||||
|
if (stored && !regions.find(r => r.id === stored.id)) {
|
||||||
|
this.clearRegion();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
// Fallback: hardcoded popular regions
|
||||||
|
this.regionsSignal.set(this.getFallbackRegions());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set region by user choice */
|
||||||
|
setRegion(region: Region): void {
|
||||||
|
this.regionSignal.set(region);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(region));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear region (go global) */
|
||||||
|
clearRegion(): void {
|
||||||
|
this.regionSignal.set(null);
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-detect user location via IP geolocation */
|
||||||
|
detectLocation(): void {
|
||||||
|
if (this.detectedSignal()) return; // already tried
|
||||||
|
this.loadingSignal.set(true);
|
||||||
|
|
||||||
|
// Using free ip-api.com — no key required, 45 req/min
|
||||||
|
this.http.get<GeoIpResponse>('http://ip-api.com/json/?fields=city,country,countryCode,region,timezone,lat,lon')
|
||||||
|
.subscribe({
|
||||||
|
next: (geo) => {
|
||||||
|
this.detectedSignal.set(true);
|
||||||
|
this.loadingSignal.set(false);
|
||||||
|
|
||||||
|
// Only auto-set if user hasn't manually chosen a region
|
||||||
|
if (!this.regionSignal()) {
|
||||||
|
const matchedRegion = this.findRegionByGeo(geo);
|
||||||
|
if (matchedRegion) {
|
||||||
|
this.setRegion(matchedRegion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.detectedSignal.set(true);
|
||||||
|
this.loadingSignal.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Try to match detected geo data to an available region */
|
||||||
|
private findRegionByGeo(geo: GeoIpResponse): Region | null {
|
||||||
|
const regions = this.regionsSignal();
|
||||||
|
if (!regions.length) return null;
|
||||||
|
|
||||||
|
// Exact city match
|
||||||
|
const cityMatch = regions.find(r =>
|
||||||
|
r.city.toLowerCase() === geo.city?.toLowerCase()
|
||||||
|
);
|
||||||
|
if (cityMatch) return cityMatch;
|
||||||
|
|
||||||
|
// Country match — pick the first region for that country
|
||||||
|
const countryMatch = regions.find(r =>
|
||||||
|
r.countryCode.toLowerCase() === geo.countryCode?.toLowerCase()
|
||||||
|
);
|
||||||
|
return countryMatch || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore previously selected region from storage */
|
||||||
|
private restoreFromStorage(): void {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const region: Region = JSON.parse(stored);
|
||||||
|
this.regionSignal.set(region);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback regions if backend /regions endpoint is unavailable */
|
||||||
|
private getFallbackRegions(): Region[] {
|
||||||
|
return [
|
||||||
|
{ 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' },
|
||||||
|
{ id: 'minsk', city: 'Минск', country: 'Беларусь', countryCode: 'BY', timezone: 'Europe/Minsk' },
|
||||||
|
{ id: 'almaty', city: 'Алматы', country: 'Казахстан', countryCode: 'KZ', timezone: 'Asia/Almaty' },
|
||||||
|
{ id: 'tbilisi', city: 'Тбилиси', country: 'Грузия', countryCode: 'GE', timezone: 'Asia/Tbilisi' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,92 @@
|
|||||||
import { Item } from '../models';
|
import { Item } from '../models';
|
||||||
|
|
||||||
export function getDiscountedPrice(item: Item): number {
|
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 {
|
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';
|
return item.photos?.[0]?.url || '/assets/images/placeholder.svg';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackByItemId(index: number, item: Item): number {
|
export function getAllImages(item: Item): string[] {
|
||||||
return item.itemID;
|
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.
|
||||||
|
* Checks translations map first, then names[]/descriptions[] arrays,
|
||||||
|
* then falls back to the default (base) field.
|
||||||
|
*/
|
||||||
|
export function getTranslatedField(
|
||||||
|
item: Item,
|
||||||
|
field: 'name' | 'simpleDescription',
|
||||||
|
lang: string
|
||||||
|
): string {
|
||||||
|
// 1. Check translations map (backOffice format)
|
||||||
|
const translation = item.translations?.[lang];
|
||||||
|
if (translation && translation[field]) {
|
||||||
|
return translation[field]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check names[]/descriptions[] arrays (backend API format)
|
||||||
|
if (field === 'name' && item.names?.length) {
|
||||||
|
const entry = item.names.find(n => n.language === lang);
|
||||||
|
if (entry) return entry.value;
|
||||||
|
}
|
||||||
|
if (field === 'simpleDescription' && item.descriptions?.length) {
|
||||||
|
const entry = item.descriptions.find(d => d.language === lang);
|
||||||
|
if (entry) return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to base field
|
||||||
|
if (field === 'name') return item.name;
|
||||||
|
if (field === 'simpleDescription') return item.simpleDescription || item.description || '';
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export const environment = {
|
|||||||
supportEmail: 'info@novo.market',
|
supportEmail: 'info@novo.market',
|
||||||
domain: 'novo.market',
|
domain: 'novo.market',
|
||||||
telegram: '@novomarket',
|
telegram: '@novomarket',
|
||||||
|
telegramBot: 'novomarket_bot',
|
||||||
phones: {
|
phones: {
|
||||||
armenia: '+374 98 731231',
|
armenia: '+374 98 731231',
|
||||||
support: '+374 98 731231'
|
support: '+374 98 731231'
|
||||||
}
|
},
|
||||||
|
useMockData: false
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export const environment = {
|
|||||||
supportEmail: 'info@novo.market',
|
supportEmail: 'info@novo.market',
|
||||||
domain: 'novo.market',
|
domain: 'novo.market',
|
||||||
telegram: '@novomarket',
|
telegram: '@novomarket',
|
||||||
|
telegramBot: 'novomarket_bot',
|
||||||
phones: {
|
phones: {
|
||||||
armenia: '+374 98 731231',
|
armenia: '+374 98 731231',
|
||||||
support: '+374 98 731231'
|
support: '+374 98 731231'
|
||||||
}
|
},
|
||||||
|
useMockData: true
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export const environment = {
|
|||||||
supportEmail: 'info@dexarmarket.ru',
|
supportEmail: 'info@dexarmarket.ru',
|
||||||
domain: 'dexarmarket.ru',
|
domain: 'dexarmarket.ru',
|
||||||
telegram: '@dexarmarket',
|
telegram: '@dexarmarket',
|
||||||
|
telegramBot: 'dexarmarket_bot',
|
||||||
phones: {
|
phones: {
|
||||||
russia: '+7 (926) 459-31-57',
|
russia: '+7 (926) 459-31-57',
|
||||||
armenia: '+374 94 86 18 16'
|
armenia: '+374 94 86 18 16'
|
||||||
}
|
},
|
||||||
|
useMockData: false
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
// Dexar Market Configuration
|
// Dexar Market Configuration
|
||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
|
useMockData: false, // Toggle to test with backOffice mock data
|
||||||
brandName: 'Dexarmarket',
|
brandName: 'Dexarmarket',
|
||||||
brandFullName: 'Dexar Market',
|
brandFullName: 'Dexar Market',
|
||||||
theme: 'dexar',
|
theme: 'dexar',
|
||||||
apiUrl: 'https://api.dexarmarket.ru:445',
|
apiUrl: '/api',
|
||||||
logo: '/assets/images/dexar-logo.svg',
|
logo: '/assets/images/dexar-logo.svg',
|
||||||
contactEmail: 'info@dexarmarket.ru',
|
contactEmail: 'info@dexarmarket.ru',
|
||||||
supportEmail: 'info@dexarmarket.ru',
|
supportEmail: 'info@dexarmarket.ru',
|
||||||
domain: 'dexarmarket.ru',
|
domain: 'dexarmarket.ru',
|
||||||
telegram: '@dexarmarket',
|
telegram: '@dexarmarket',
|
||||||
|
telegramBot: 'dexarmarket_bot',
|
||||||
phones: {
|
phones: {
|
||||||
russia: '+7 (926) 459-31-57',
|
russia: '+7 (926) 459-31-57',
|
||||||
armenia: '+374 94 86 18 16'
|
armenia: '+374 94 86 18 16'
|
||||||
|
|||||||
@@ -140,3 +140,58 @@ a, button, input, textarea, select {
|
|||||||
.p-3 { padding: 1.5rem; }
|
.p-3 { padding: 1.5rem; }
|
||||||
.p-4 { padding: 2rem; }
|
.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