Compare commits
52 Commits
auth-syste
...
6e5fb3b86a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e5fb3b86a | ||
|
|
a15f2bca6a | ||
|
|
1897cbe7a6 | ||
|
|
ab1732d74b | ||
|
|
7df15a4243 | ||
|
|
abb74390e8 | ||
|
|
06a7568386 | ||
|
|
77737f0ba9 | ||
|
|
6de461473e | ||
|
|
db781fd871 | ||
|
|
ce301e9c70 | ||
|
|
64288b5ce1 | ||
|
|
a8bb725f78 | ||
|
|
df2208ab53 | ||
|
|
72deb8d5e3 | ||
|
|
5566e011b7 | ||
|
|
ee23fd2d3c | ||
|
|
2a41062769 | ||
|
|
6624de7a32 | ||
|
|
44553f5bd4 | ||
|
|
5ed255dddb | ||
|
|
650bf137f2 | ||
|
|
3a8bc2f893 | ||
|
|
d29de100c6 | ||
|
|
97214c3a90 | ||
|
|
56f4c56b9e | ||
|
|
0b3b2ee463 | ||
|
|
c3e4e695eb | ||
|
|
c112aded47 | ||
|
|
75f029b872 | ||
|
|
f823df7e15 | ||
|
|
af78c053ba | ||
|
|
4ef4223367 | ||
|
|
7b18376d28 | ||
|
|
c64b9cfee8 | ||
|
|
712281d2e8 | ||
|
|
0626dcbe46 | ||
|
|
d288a5fb3c | ||
|
|
3445f55758 | ||
|
|
350581cbe9 | ||
|
|
377da22761 | ||
|
|
421346d957 | ||
|
|
d6097e2b5d | ||
|
|
369af40f20 | ||
|
|
75b45abe4f | ||
|
|
2bd98b29eb | ||
|
|
82cbf07120 | ||
|
|
e07356a700 | ||
|
|
5068a3a114 | ||
|
|
333ea45c38 | ||
|
|
b22390f3eb | ||
|
|
3f285ca15f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -38,7 +38,7 @@ yarn-error.log
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
/public/images/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
27
angular.json
27
angular.json
@@ -154,7 +154,8 @@
|
||||
},
|
||||
"serve": {
|
||||
"options": {
|
||||
"allowedHosts": ["novo.market", "dexarmarket.ru", "localhost"]
|
||||
"allowedHosts": ["novo.market", "dexarmarket.ru", "dexar.market","localhost"],
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
@@ -165,7 +166,8 @@
|
||||
"buildTarget": "Dexarmarket:build:development"
|
||||
},
|
||||
"novo": {
|
||||
"buildTarget": "Dexarmarket:build:novo"
|
||||
"buildTarget": "Dexarmarket:build:novo",
|
||||
"proxyConfig": "proxy.conf.novo.json"
|
||||
},
|
||||
"novo-production": {
|
||||
"buildTarget": "Dexarmarket:build:novo-production"
|
||||
@@ -175,28 +177,9 @@
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,471 @@
|
||||
# API Changes Required for Backend
|
||||
# Complete Backend API Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Frontend has been updated with two new features:
|
||||
1. **Region/Location system** — catalog filtering by region
|
||||
2. **Auth/Login system** — Telegram-based authentication required before payment
|
||||
|
||||
Base URLs:
|
||||
- Dexar: `https://api.dexarmarket.ru:445`
|
||||
- Novo: `https://api.novo.market:444`
|
||||
> **Last updated:** February 2026
|
||||
> **Frontend:** Angular 21 · Dual-brand (Dexar + Novo)
|
||||
> **Covers:** Catalog, Cart, Payments, Reviews, Regions, Auth, i18n, BackOffice
|
||||
|
||||
---
|
||||
|
||||
## 1. Region / Location Endpoints
|
||||
## Base URLs
|
||||
|
||||
### 1.1 `GET /regions` — List available regions
|
||||
| Brand | Dev | Production |
|
||||
|--------|----------------------------------|----------------------------------|
|
||||
| Dexar | `https://api.dexarmarket.ru:445` | `https://api.dexarmarket.ru:445` |
|
||||
| Novo | `https://api.novo.market:444` | `https://api.novo.market:444` |
|
||||
|
||||
Returns the list of regions where the marketplace operates.
|
||||
---
|
||||
|
||||
**Response** `200 OK`
|
||||
## Global HTTP Headers
|
||||
|
||||
The frontend **automatically attaches** two custom headers to **every API request** via an interceptor. The backend should read these headers and use them to filter/translate responses accordingly.
|
||||
|
||||
| Header | Example Value | Description |
|
||||
|---------------|---------------|------------------------------------------------------------|
|
||||
| `X-Region` | `moscow` | Region ID selected by the user. **Absent** = global (all). |
|
||||
| `X-Language` | `ru` | Active UI language: `ru`, `en`, or `hy`. |
|
||||
|
||||
### Backend behavior
|
||||
|
||||
- **`X-Region`**: If present, filter items/categories to only those available in that region. If absent, return everything (global catalog).
|
||||
- **`X-Language`**: If present, return translated `name`, `description`, etc. for categories/items when translations exist. If absent or `ru`, use russians defaults.
|
||||
|
||||
### CORS requirements for these headers
|
||||
|
||||
```
|
||||
Access-Control-Allow-Headers: Content-Type, X-Region, X-Language
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Health Check
|
||||
|
||||
### `GET /ping`
|
||||
|
||||
Simple health check.
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "pong" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Catalog — Categories
|
||||
|
||||
### `GET /category`
|
||||
|
||||
Returns all top-level categories. Respects `X-Region` and `X-Language` headers.
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"categoryID": 1,
|
||||
"name": "Электроника",
|
||||
"parentID": 0,
|
||||
"icon": "https://...",
|
||||
"wideBanner": "https://...",
|
||||
"itemCount": 42,
|
||||
"priority": 10,
|
||||
|
||||
"id": "cat_abc123",
|
||||
"visible": true,
|
||||
"img": "https://...",
|
||||
"projectId": "proj_xyz",
|
||||
"subcategories": [
|
||||
{
|
||||
"id": "sub_001",
|
||||
"name": "Смартфоны",
|
||||
"visible": true,
|
||||
"priority": 5,
|
||||
"img": "https://...",
|
||||
"categoryId": "cat_abc123",
|
||||
"parentId": "cat_abc123",
|
||||
"itemCount": 20,
|
||||
"hasItems": true,
|
||||
"subcategories": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Category object:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------------|----------|----------------------------------------------------|
|
||||
| `categoryID` | number | yes | Legacy numeric ID |
|
||||
| `name` | string | yes | Category display name (translated if `X-Language`) |
|
||||
| `parentID` | number | yes | Parent category ID (`0` = top-level) |
|
||||
| `icon` | string | no | Category icon URL |
|
||||
| `wideBanner` | string | no | Wide banner image URL |
|
||||
| `itemCount` | number | no | Number of items in category |
|
||||
| `priority` | number | no | Sort priority (higher = first) |
|
||||
| `id` | string | no | BackOffice string ID |
|
||||
| `visible` | boolean | no | Whether category is shown (`true` default) |
|
||||
| `img` | string | no | BackOffice image URL (maps to `icon`) |
|
||||
| `projectId` | string | no | BackOffice project reference |
|
||||
| `subcategories` | Subcategory[] | no | Nested subcategories |
|
||||
|
||||
**Subcategory object:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------------|----------|------------------------------------|
|
||||
| `id` | string | yes | Subcategory ID |
|
||||
| `name` | string | yes | Display name |
|
||||
| `visible` | boolean | no | Whether visible |
|
||||
| `priority` | number | no | Sort priority |
|
||||
| `img` | string | no | Image URL |
|
||||
| `categoryId` | string | yes | Parent category ID |
|
||||
| `parentId` | string | yes | Direct parent ID |
|
||||
| `itemCount` | number | no | Number of items |
|
||||
| `hasItems` | boolean | no | Whether has any items |
|
||||
| `subcategories` | Subcategory[] | no | Nested children |
|
||||
|
||||
---
|
||||
|
||||
### `GET /category/:categoryID`
|
||||
|
||||
Returns items in a specific category. Respects `X-Region` and `X-Language` headers.
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|----------|--------|---------|--------------------|
|
||||
| `count` | number | `50` | Items per page |
|
||||
| `skip` | number | `0` | Offset for paging |
|
||||
|
||||
**Response `200`:** Array of [Item](#item-object) objects.
|
||||
|
||||
---
|
||||
|
||||
## 3. Items
|
||||
|
||||
### `GET /item/:itemID`
|
||||
|
||||
Returns a single item. Respects `X-Region` and `X-Language` headers.
|
||||
|
||||
**Response `200`:** A single [Item](#item-object) object.
|
||||
|
||||
---
|
||||
|
||||
### `GET /searchitems`
|
||||
|
||||
Full-text search across items. Respects `X-Region` and `X-Language` headers.
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|----------|--------|---------|----------------------|
|
||||
| `search` | string | — | Search query (required) |
|
||||
| `count` | number | `50` | Items per page |
|
||||
| `skip` | number | `0` | Offset for paging |
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"items": [ /* Item objects */ ],
|
||||
"total": 128,
|
||||
"count": 50,
|
||||
"skip": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /randomitems`
|
||||
|
||||
Returns random items for carousel/recommendations. Respects `X-Region` and `X-Language` headers.
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|------------|--------|---------|------------------------------------|
|
||||
| `count` | number | `5` | Number of items to return |
|
||||
| `category` | number | — | Optional: limit to this category |
|
||||
|
||||
**Response `200`:** Array of [Item](#item-object) objects.
|
||||
|
||||
---
|
||||
|
||||
### Item Object
|
||||
|
||||
The backend can return items in **either** legacy format or BackOffice format. The frontend normalizes both.
|
||||
|
||||
```json
|
||||
{
|
||||
"categoryID": 1,
|
||||
"itemID": 123,
|
||||
"name": "iPhone 15 Pro",
|
||||
"photos": [{ "url": "https://..." }],
|
||||
"description": "Описание товара",
|
||||
"currency": "RUB",
|
||||
"price": 89990,
|
||||
"discount": 10,
|
||||
"remainings": "high",
|
||||
"rating": 4.5,
|
||||
"callbacks": [
|
||||
{
|
||||
"rating": 5,
|
||||
"content": "Отличный товар!",
|
||||
"userID": "user_123",
|
||||
"timestamp": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"questions": [
|
||||
{
|
||||
"question": "Есть ли гарантия?",
|
||||
"answer": "Да, 12 месяцев",
|
||||
"upvotes": 5,
|
||||
"downvotes": 0
|
||||
}
|
||||
],
|
||||
|
||||
"id": "item_abc123",
|
||||
"visible": true,
|
||||
"priority": 10,
|
||||
"imgs": ["https://img1.jpg", "https://img2.jpg"],
|
||||
"tags": ["new", "popular"],
|
||||
"badges": ["bestseller", "sale"],
|
||||
"simpleDescription": "Краткое описание",
|
||||
"descriptionFields": [
|
||||
{ "key": "Процессор", "value": "A17 Pro" },
|
||||
{ "key": "Память", "value": "256 GB" }
|
||||
],
|
||||
"subcategoryId": "sub_001",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Short description",
|
||||
"description": [
|
||||
{ "key": "Processor", "value": "A17 Pro" }
|
||||
]
|
||||
},
|
||||
"hy": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Կարcheck check check"
|
||||
}
|
||||
},
|
||||
"comments": [
|
||||
{
|
||||
"id": "cmt_001",
|
||||
"text": "Отличный товар!",
|
||||
"author": "user_123",
|
||||
"stars": 5,
|
||||
"createdAt": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"quantity": 50
|
||||
}
|
||||
```
|
||||
|
||||
**Full Item fields:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------------|-------------------|----------|------------------------------------------------------------|
|
||||
| `categoryID` | number | yes | Category this item belongs to |
|
||||
| `itemID` | number | yes | Legacy numeric item ID |
|
||||
| `name` | string | yes | Item display name |
|
||||
| `photos` | Photo[] | no | Legacy photo array `[{ url }]` |
|
||||
| `description` | string | yes | Text description |
|
||||
| `currency` | string | yes | Currency code (default: `RUB`) |
|
||||
| `price` | number | yes | Price in the currency's smallest display unit |
|
||||
| `discount` | number | yes | Discount percentage (`0`–`100`) |
|
||||
| `remainings` | string | no | Stock level: `high`, `medium`, `low`, `out` |
|
||||
| `rating` | number | yes | Average rating (`0`–`5`) |
|
||||
| `callbacks` | Review[] | no | Legacy reviews (alias for reviews) |
|
||||
| `questions` | Question[] | no | Q&A entries |
|
||||
| `id` | string | no | BackOffice string ID |
|
||||
| `visible` | boolean | no | Whether item is visible (`true` default) |
|
||||
| `priority` | number | no | Sort priority (higher = first) |
|
||||
| `imgs` | string[] | no | BackOffice image URLs (maps to `photos`) |
|
||||
| `tags` | string[] | no | Item tags for filtering |
|
||||
| `badges` | string[] | no | Display badges (`bestseller`, `sale`, etc.) |
|
||||
| `simpleDescription` | string | no | Short plain-text description |
|
||||
| `descriptionFields` | DescriptionField[]| no | Structured `[{ key, value }]` descriptions |
|
||||
| `subcategoryId` | string | no | BackOffice subcategory reference |
|
||||
| `translations` | Record | no | Translations keyed by lang code (see below) |
|
||||
| `comments` | Comment[] | no | BackOffice comments format |
|
||||
| `quantity` | number | no | Numeric stock count (maps to `remainings` on frontend) |
|
||||
|
||||
**Nested types:**
|
||||
|
||||
| Type | Fields |
|
||||
|--------------------|-----------------------------------------------------------------|
|
||||
| `Photo` | `url: string`, `photo?: string`, `video?: string`, `type?: string` |
|
||||
| `DescriptionField` | `key: string`, `value: string` |
|
||||
| `Comment` | `id?: string`, `text: string`, `author?: string`, `stars?: number`, `createdAt?: string` |
|
||||
| `Review` | `rating?: number`, `content?: string`, `userID?: string`, `answer?: string`, `timestamp?: string` |
|
||||
| `Question` | `question: string`, `answer: string`, `upvotes: number`, `downvotes: number` |
|
||||
| `ItemTranslation` | `name?: string`, `simpleDescription?: string`, `description?: DescriptionField[]` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Cart
|
||||
|
||||
### `POST /cart` — Add item to cart
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "itemID": 123, "quantity": 1 }
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Added to cart" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /cart` — Update item quantity
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "itemID": 123, "quantity": 3 }
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Updated" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /cart` — Remove items from cart
|
||||
|
||||
**Request body:** Array of item IDs
|
||||
```json
|
||||
[123, 456]
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Removed" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /cart` — Get cart contents
|
||||
|
||||
**Response `200`:** Array of [Item](#item-object) objects (each with `quantity` field).
|
||||
|
||||
---
|
||||
|
||||
## 5. Payments (SBP / QR)
|
||||
|
||||
### `POST /cart` — Create payment (SBP QR)
|
||||
|
||||
> Note: Same endpoint as add-to-cart but with different body schema.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"amount": 89990,
|
||||
"currency": "RUB",
|
||||
"siteuserID": "tg_123456789",
|
||||
"siteorderID": "order_abc123",
|
||||
"redirectUrl": "",
|
||||
"telegramUsername": "john_doe",
|
||||
"items": [
|
||||
{ "itemID": 123, "price": 89990, "name": "iPhone 15 Pro" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"qrId": "qr_abc123",
|
||||
"qrStatus": "CREATED",
|
||||
"qrExpirationDate": "2026-02-28T13:00:00Z",
|
||||
"payload": "https://qr.nspk.ru/...",
|
||||
"qrUrl": "https://qr.nspk.ru/..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /qr/payment/:qrId` — Check payment status
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"additionalInfo": "",
|
||||
"paymentPurpose": "Order #order_abc123",
|
||||
"amount": 89990,
|
||||
"code": "SUCCESS",
|
||||
"createDate": "2026-02-28T12:00:00Z",
|
||||
"currency": "RUB",
|
||||
"order": "order_abc123",
|
||||
"paymentStatus": "COMPLETED",
|
||||
"qrId": "qr_abc123",
|
||||
"transactionDate": "2026-02-28T12:01:00Z",
|
||||
"transactionId": 999,
|
||||
"qrExpirationDate": "2026-02-28T13:00:00Z",
|
||||
"phoneNumber": "+7XXXXXXXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
| `paymentStatus` values | Meaning |
|
||||
|------------------------|---------------------------|
|
||||
| `CREATED` | QR generated, not paid |
|
||||
| `WAITING` | Payment in progress |
|
||||
| `COMPLETED` | Payment successful |
|
||||
| `EXPIRED` | QR code expired |
|
||||
| `CANCELLED` | Payment cancelled |
|
||||
|
||||
---
|
||||
|
||||
### `POST /purchase-email` — Submit email after payment
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"telegramUserId": "123456789",
|
||||
"items": [
|
||||
{ "itemID": 123, "name": "iPhone 15 Pro", "price": 89990, "currency": "RUB" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Email sent" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Reviews / Comments
|
||||
|
||||
### `POST /comment` — Submit a review
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"itemID": 123,
|
||||
"rating": 5,
|
||||
"comment": "Great product!",
|
||||
"username": "john_doe",
|
||||
"userId": 123456789,
|
||||
"timestamp": "2026-02-28T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Review submitted" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Regions
|
||||
|
||||
### `GET /regions` — List available regions
|
||||
|
||||
Returns regions where the marketplace operates.
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
@@ -46,52 +493,44 @@ Returns the list of regions where the marketplace operates.
|
||||
```
|
||||
|
||||
**Region object:**
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|--------|----------|------------------------------|
|
||||
| `id` | string | yes | Unique region identifier |
|
||||
| `city` | string | yes | City name (display) |
|
||||
| `country` | string | yes | Country name (display) |
|
||||
| `countryCode` | string | yes | ISO 3166-1 alpha-2 code |
|
||||
| `timezone` | string | no | IANA timezone string |
|
||||
|
||||
> If this endpoint is unavailable, the frontend falls back to 6 hardcoded regions (Moscow, SPB, Yerevan, Minsk, Almaty, Tbilisi).
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|--------|----------|--------------------------|
|
||||
| `id` | string | yes | Unique region identifier |
|
||||
| `city` | string | yes | City name (display) |
|
||||
| `country` | string | yes | Country name |
|
||||
| `countryCode` | string | yes | ISO 3166-1 alpha-2 |
|
||||
| `timezone` | string | no | IANA timezone |
|
||||
|
||||
> **Fallback:** If this endpoint is down, the frontend uses 6 hardcoded defaults: Moscow, SPB, Yerevan, Minsk, Almaty, Tbilisi.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Region Query Parameter on Existing Endpoints
|
||||
|
||||
The following **existing** endpoints now accept an optional `?region=` query parameter:
|
||||
|
||||
| Endpoint | Example |
|
||||
|---------------------------------|----------------------------------------------|
|
||||
| `GET /category` | `GET /category?region=moscow` |
|
||||
| `GET /category/:id` | `GET /category/5?count=50&skip=0®ion=spb` |
|
||||
| `GET /item/:id` | `GET /item/123?region=yerevan` |
|
||||
| `GET /searchitems` | `GET /searchitems?search=phone®ion=moscow` |
|
||||
| `GET /randomitems` | `GET /randomitems?count=5®ion=almaty` |
|
||||
|
||||
**Behavior:**
|
||||
- If `region` param is **present** → return only items/categories available in that region
|
||||
- If `region` param is **absent** → return all items globally (current behavior, no change)
|
||||
- The `region` value matches the `id` field from the `/regions` response
|
||||
|
||||
---
|
||||
|
||||
## 2. Auth / Login Endpoints
|
||||
## 8. Authentication (Telegram Login)
|
||||
|
||||
Authentication is **Telegram-based** with **cookie sessions** (HttpOnly, Secure, SameSite=None).
|
||||
|
||||
All auth endpoints must support CORS with `credentials: true`.
|
||||
All auth endpoints must include `withCredentials: true` CORS support.
|
||||
|
||||
### 2.1 `GET /auth/session` — Check current session
|
||||
### Auth flow
|
||||
|
||||
Called on every page load to check if the user has an active session.
|
||||
```
|
||||
1. User clicks "Checkout" → not authenticated → login dialog shown
|
||||
2. User clicks "Log in with Telegram" → opens https://t.me/{bot}?start=auth_{callback}
|
||||
3. User starts the bot in Telegram
|
||||
4. Bot sends user data → backend /auth/telegram/callback
|
||||
5. Backend creates session → sets Set-Cookie
|
||||
6. Frontend polls GET /auth/session every 3s
|
||||
7. Session detected → dialog closes → checkout proceeds
|
||||
```
|
||||
|
||||
**Request:**
|
||||
- Cookies: session cookie (set by backend)
|
||||
- CORS: `withCredentials: true`
|
||||
---
|
||||
|
||||
**Response `200 OK`** (authenticated):
|
||||
### `GET /auth/session` — Check current session
|
||||
|
||||
**Request:** Cookies only (session cookie set by backend).
|
||||
|
||||
**Response `200`** (authenticated):
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
@@ -103,7 +542,7 @@ Called on every page load to check if the user has an active session.
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200 OK`** (expired session):
|
||||
**Response `200`** (expired):
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
@@ -115,37 +554,29 @@ Called on every page load to check if the user has an active session.
|
||||
}
|
||||
```
|
||||
|
||||
**Response `401 Unauthorized`** (no session / invalid cookie):
|
||||
**Response `401`** (no session):
|
||||
```json
|
||||
{
|
||||
"error": "No active session"
|
||||
}
|
||||
{ "error": "No active session" }
|
||||
```
|
||||
|
||||
**AuthSession object:**
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------|----------|------------------------------------------|
|
||||
| `sessionId` | string | yes | Unique session ID |
|
||||
| `telegramUserId` | number | yes | Telegram user ID |
|
||||
| `username` | string? | no | Telegram @username (can be null) |
|
||||
| `displayName` | string | yes | User display name (first_name + last_name) |
|
||||
| `active` | boolean | yes | Whether session is currently valid |
|
||||
| `expiresAt` | string | yes | ISO 8601 expiration datetime |
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------|----------|--------------------------------------------|
|
||||
| `sessionId` | string | yes | Unique session ID |
|
||||
| `telegramUserId` | number | yes | Telegram user ID |
|
||||
| `username` | string? | no | Telegram @username (can be null) |
|
||||
| `displayName` | string | yes | User display name (first + last) |
|
||||
| `active` | boolean | yes | Whether session is valid |
|
||||
| `expiresAt` | string | yes | ISO 8601 expiration datetime |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `GET /auth/telegram/callback` — Telegram bot auth callback
|
||||
### `GET /auth/telegram/callback` — Telegram bot auth callback
|
||||
|
||||
This is the URL the Telegram bot redirects to after the user starts the bot.
|
||||
Called by the Telegram bot after user authenticates.
|
||||
|
||||
**Flow:**
|
||||
1. Frontend generates link: `https://t.me/{botUsername}?start=auth_{encodedCallbackUrl}`
|
||||
2. User clicks → opens Telegram → starts the bot
|
||||
3. Bot sends user data to this callback endpoint
|
||||
4. Backend creates session, sets `Set-Cookie` header
|
||||
5. Frontend polls `GET /auth/session` every 3 seconds to detect when session becomes active
|
||||
|
||||
**Request** (from Telegram bot / webhook):
|
||||
**Request body (from bot):**
|
||||
```json
|
||||
{
|
||||
"id": 123456789,
|
||||
@@ -158,7 +589,7 @@ This is the URL the Telegram bot redirects to after the user starts the bot.
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Should set a session cookie and return:
|
||||
**Response:** Must set a session cookie and return:
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
@@ -167,93 +598,89 @@ This is the URL the Telegram bot redirects to after the user starts the bot.
|
||||
```
|
||||
|
||||
**Cookie requirements:**
|
||||
| Attribute | Value | Notes |
|
||||
|------------|----------------|------------------------------------------|
|
||||
| `HttpOnly` | `true` | Not accessible via JS |
|
||||
| `Secure` | `true` | HTTPS only |
|
||||
|
||||
| Attribute | Value | Notes |
|
||||
|------------|----------------|--------------------------------------------|
|
||||
| `HttpOnly` | `true` | Not accessible via JS |
|
||||
| `Secure` | `true` | HTTPS only |
|
||||
| `SameSite` | `None` | Required for cross-origin (API ≠ frontend) |
|
||||
| `Path` | `/` | |
|
||||
| `Max-Age` | `86400` (24h) | Or as needed |
|
||||
| `Domain` | API domain | |
|
||||
|
||||
> **Important:** Since the API domain differs from the frontend domain, `SameSite=None` + `Secure=true` is required for the cookie to be sent cross-origin.
|
||||
| `Path` | `/` | |
|
||||
| `Max-Age` | `86400` (24h) | Or as needed |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `POST /auth/logout` — End session
|
||||
### `POST /auth/logout` — End session
|
||||
|
||||
**Request:**
|
||||
- Cookies: session cookie
|
||||
- CORS: `withCredentials: true`
|
||||
- Body: `{}` (empty)
|
||||
**Request:** Cookies only, empty body `{}`
|
||||
|
||||
**Response `200 OK`:**
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"message": "Logged out"
|
||||
}
|
||||
{ "message": "Logged out" }
|
||||
```
|
||||
|
||||
Should clear/invalidate the session cookie.
|
||||
Must clear/invalidate the session cookie.
|
||||
|
||||
---
|
||||
|
||||
## 3. CORS Configuration
|
||||
### Session refresh
|
||||
|
||||
For auth cookies to work cross-origin, the backend CORS config must include:
|
||||
The frontend re-checks the session **60 seconds before `expiresAt`**. If the backend supports sliding expiration, it can reset the cookie's `Max-Age` on each `GET /auth/session`.
|
||||
|
||||
---
|
||||
|
||||
## 9. i18n / Translations
|
||||
|
||||
The frontend supports 3 languages: **Russian (ru)**, **English (en)**, **Armenian (hy)**.
|
||||
|
||||
The active language is sent via the `X-Language` HTTP header on every request.
|
||||
|
||||
### What the backend should do with `X-Language`
|
||||
|
||||
1. **Categories & items**: If `translations` field exists for the requested language, return the translated `name`, `description`, etc. OR the backend can apply translations server-side and return already-translated fields.
|
||||
|
||||
2. **The `translations` field** on items (optional approach):
|
||||
```json
|
||||
{
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Short desc in English",
|
||||
"description": [{ "key": "Processor", "value": "A17 Pro" }]
|
||||
},
|
||||
"hy": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Կarcheck check"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Recommended approach**: Read `X-Language` header and return the `name`/`description` in that language directly. If no translation exists, return the Russian default.
|
||||
|
||||
---
|
||||
|
||||
## 10. CORS Configuration
|
||||
|
||||
For auth cookies and custom headers to work, the backend CORS config must include:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: https://dexarmarket.ru (NOT *)
|
||||
Access-Control-Allow-Origin: https://dexarmarket.ru (NOT wildcard *)
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Content-Type, Authorization
|
||||
Access-Control-Allow-Headers: Content-Type, X-Region, X-Language
|
||||
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
|
||||
```
|
||||
|
||||
> `Access-Control-Allow-Origin` **cannot** be `*` when `Allow-Credentials: true`. Must be the exact frontend origin.
|
||||
> **Important:** `Access-Control-Allow-Origin` cannot be `*` when `Allow-Credentials: true`. Must be the exact frontend origin.
|
||||
|
||||
For Novo, also allow `https://novo.market`.
|
||||
**Allowed origins:**
|
||||
- `https://dexarmarket.ru`
|
||||
- `https://novo.market`
|
||||
- `http://localhost:4200` (dev)
|
||||
- `http://localhost:4201` (dev, Novo)
|
||||
|
||||
---
|
||||
|
||||
## 4. Session Refresh Behavior
|
||||
|
||||
The frontend automatically re-checks the session **60 seconds before `expiresAt`**. If the backend supports session extension (sliding expiration), it can re-set the cookie with a fresh `Max-Age` on every `GET /auth/session` call.
|
||||
|
||||
---
|
||||
|
||||
## 5. Auth Gate — Checkout Flow
|
||||
|
||||
The checkout button (`POST /cart` payment) now requires authentication:
|
||||
- If the user is **not logged in** → frontend shows a Telegram login dialog instead of proceeding
|
||||
- If the user **is logged in** → checkout proceeds normally
|
||||
- The session cookie is sent automatically with the payment request
|
||||
|
||||
No backend changes needed for the payment endpoint itself — just ensure it reads the session cookie if needed for order association.
|
||||
|
||||
---
|
||||
|
||||
## Summary of New Endpoints
|
||||
|
||||
| Method | Path | Purpose | Auth Required |
|
||||
|--------|----------------------------|-----------------------------|---------------|
|
||||
| `GET` | `/regions` | List available regions | No |
|
||||
| `GET` | `/auth/session` | Check current session | Cookie |
|
||||
| `GET` | `/auth/telegram/callback` | Telegram bot auth callback | No (from bot) |
|
||||
| `POST` | `/auth/logout` | End session | Cookie |
|
||||
|
||||
## Summary of Modified Endpoints
|
||||
|
||||
| Method | Path | Change |
|
||||
|--------|-------------------|---------------------------------------|
|
||||
| `GET` | `/category` | Added optional `?region=` param |
|
||||
| `GET` | `/category/:id` | Added optional `?region=` param |
|
||||
| `GET` | `/item/:id` | Added optional `?region=` param |
|
||||
| `GET` | `/searchitems` | Added optional `?region=` param |
|
||||
| `GET` | `/randomitems` | Added optional `?region=` param |
|
||||
|
||||
---
|
||||
|
||||
## Telegram Bot Setup
|
||||
## 11. Telegram Bot Setup
|
||||
|
||||
Each brand needs its own bot:
|
||||
- **Dexar:** `@dexarmarket_bot`
|
||||
@@ -262,5 +689,38 @@ Each brand needs its own bot:
|
||||
The bot should:
|
||||
1. Listen for `/start auth_{callbackUrl}` command
|
||||
2. Extract the callback URL
|
||||
3. Send the user's Telegram data (id, first_name, username, etc.) to that callback URL
|
||||
3. Send the user's Telegram data (`id`, `first_name`, `username`, etc.) to that callback URL
|
||||
4. The callback URL is `{apiUrl}/auth/telegram/callback`
|
||||
|
||||
---
|
||||
|
||||
## Complete Endpoint Reference
|
||||
|
||||
### New endpoints
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|---------------------------|----------------------------|----------|
|
||||
| `GET` | `/regions` | List available regions | No |
|
||||
| `GET` | `/auth/session` | Check current session | Cookie |
|
||||
| `GET` | `/auth/telegram/callback` | Telegram bot auth callback | No (bot) |
|
||||
| `POST` | `/auth/logout` | End session | Cookie |
|
||||
|
||||
### Existing endpoints
|
||||
|
||||
| Method | Path | Description | Auth | Headers |
|
||||
|----------|-----------------------|-------------------------|------|--------------------|
|
||||
| `GET` | `/ping` | Health check | No | — |
|
||||
| `GET` | `/category` | List categories | No | X-Region, X-Language |
|
||||
| `GET` | `/category/:id` | Items in category | No | X-Region, X-Language |
|
||||
| `GET` | `/item/:id` | Single item | No | X-Region, X-Language |
|
||||
| `GET` | `/searchitems` | Search items | No | X-Region, X-Language |
|
||||
| `GET` | `/randomitems` | Random items | No | X-Region, X-Language |
|
||||
| `POST` | `/cart` | Add to cart / Payment | No* | — |
|
||||
| `PATCH` | `/cart` | Update cart quantity | No* | — |
|
||||
| `DELETE` | `/cart` | Remove from cart | No* | — |
|
||||
| `GET` | `/cart` | Get cart contents | No* | — |
|
||||
| `POST` | `/comment` | Submit review | No | — |
|
||||
| `GET` | `/qr/payment/:qrId` | Check payment status | No | — |
|
||||
| `POST` | `/purchase-email` | Submit email after pay | No | — |
|
||||
|
||||
> \* Cart/payment endpoints may use the session cookie if available for order association, but don't strictly require auth. The frontend enforces auth before checkout.
|
||||
|
||||
726
docs/API_DOCS_RU.md
Normal file
726
docs/API_DOCS_RU.md
Normal file
@@ -0,0 +1,726 @@
|
||||
# Полная документация Backend API
|
||||
|
||||
> **Последнее обновление:** Февраль 2026
|
||||
> **Фронтенд:** Angular 21 · Два бренда (Dexar + Novo)
|
||||
> **Охватывает:** Каталог, Корзина, Оплата, Отзывы, Регионы, Авторизация, i18n, BackOffice
|
||||
|
||||
---
|
||||
|
||||
## Базовые URL
|
||||
|
||||
| Бренд | Dev | Production |
|
||||
|--------|----------------------------------|----------------------------------|
|
||||
| Dexar | `https://api.dexarmarket.ru:445` | `https://api.dexarmarket.ru:445` |
|
||||
| Novo | `https://api.novo.market:444` | `https://api.novo.market:444` |
|
||||
|
||||
---
|
||||
|
||||
## Глобальные HTTP-заголовки
|
||||
|
||||
Фронтенд **автоматически добавляет** два кастомных заголовка к **каждому API-запросу** через interceptor. Бэкенд должен читать эти заголовки и использовать для фильтрации/перевода ответов.
|
||||
|
||||
| Заголовок | Пример значения | Описание |
|
||||
|---------------|-----------------|-------------------------------------------------------------------|
|
||||
| `X-Region` | `moscow` | ID региона, выбранного пользователем. **Отсутствует** = все регионы. |
|
||||
| `X-Language` | `ru` | Активный язык интерфейса: `ru`, `en` или `hy`. |
|
||||
|
||||
### Поведение бэкенда
|
||||
|
||||
- **`X-Region`**: Если присутствует — фильтровать товары/категории только по этому региону. Если отсутствует — возвращать всё (глобальный каталог).
|
||||
- **`X-Language`**: Если присутствует — возвращать переведённые `name`, `description` и т.д., если переводы существуют. Если отсутствует или `ru` — возвращать на русском (по умолчанию).
|
||||
|
||||
### Требования CORS для этих заголовков
|
||||
|
||||
```
|
||||
Access-Control-Allow-Headers: Content-Type, X-Region, X-Language
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Проверка состояния
|
||||
|
||||
### `GET /ping`
|
||||
|
||||
Простая проверка работоспособности.
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "pong" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Каталог — Категории
|
||||
|
||||
### `GET /category`
|
||||
|
||||
Возвращает все категории верхнего уровня. Учитывает заголовки `X-Region` и `X-Language`.
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"categoryID": 1,
|
||||
"name": "Электроника",
|
||||
"parentID": 0,
|
||||
"icon": "https://...",
|
||||
"wideBanner": "https://...",
|
||||
"itemCount": 42,
|
||||
"priority": 10,
|
||||
|
||||
"id": "cat_abc123",
|
||||
"visible": true,
|
||||
"img": "https://...",
|
||||
"projectId": "proj_xyz",
|
||||
"subcategories": [
|
||||
{
|
||||
"id": "sub_001",
|
||||
"name": "Смартфоны",
|
||||
"visible": true,
|
||||
"priority": 5,
|
||||
"img": "https://...",
|
||||
"categoryId": "cat_abc123",
|
||||
"parentId": "cat_abc123",
|
||||
"itemCount": 20,
|
||||
"hasItems": true,
|
||||
"subcategories": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Объект Category:**
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|------------------|---------------|---------|-----------------------------------------------------|
|
||||
| `categoryID` | number | да | Числовой ID (legacy) |
|
||||
| `name` | string | да | Название категории (переведённое если `X-Language`) |
|
||||
| `parentID` | number | да | ID родительской категории (`0` = верхний уровень) |
|
||||
| `icon` | string | нет | URL иконки категории |
|
||||
| `wideBanner` | string | нет | URL широкого баннера |
|
||||
| `itemCount` | number | нет | Количество товаров в категории |
|
||||
| `priority` | number | нет | Приоритет сортировки (больше = выше) |
|
||||
| `id` | string | нет | Строковый ID из BackOffice |
|
||||
| `visible` | boolean | нет | Видима ли категория (по умолч. `true`) |
|
||||
| `img` | string | нет | URL изображения из BackOffice (маппится на `icon`) |
|
||||
| `projectId` | string | нет | Ссылка на проект в BackOffice |
|
||||
| `subcategories` | Subcategory[] | нет | Вложенные подкатегории |
|
||||
|
||||
**Объект Subcategory:**
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|------------------|---------------|---------|----------------------------------|
|
||||
| `id` | string | да | ID подкатегории |
|
||||
| `name` | string | да | Отображаемое название |
|
||||
| `visible` | boolean | нет | Видима ли |
|
||||
| `priority` | number | нет | Приоритет сортировки |
|
||||
| `img` | string | нет | URL изображения |
|
||||
| `categoryId` | string | да | ID родительской категории |
|
||||
| `parentId` | string | да | ID прямого родителя |
|
||||
| `itemCount` | number | нет | Количество товаров |
|
||||
| `hasItems` | boolean | нет | Есть ли товары |
|
||||
| `subcategories` | Subcategory[] | нет | Вложенные дочерние подкатегории |
|
||||
|
||||
---
|
||||
|
||||
### `GET /category/:categoryID`
|
||||
|
||||
Возвращает товары определённой категории. Учитывает заголовки `X-Region` и `X-Language`.
|
||||
|
||||
**Query-параметры:**
|
||||
|
||||
| Параметр | Тип | По умолч. | Описание |
|
||||
|----------|--------|-----------|-----------------------|
|
||||
| `count` | number | `50` | Товаров на страницу |
|
||||
| `skip` | number | `0` | Смещение для пагинации |
|
||||
|
||||
**Ответ `200`:** Массив объектов [Item](#объект-item).
|
||||
|
||||
---
|
||||
|
||||
## 3. Товары
|
||||
|
||||
### `GET /item/:itemID`
|
||||
|
||||
Возвращает один товар. Учитывает заголовки `X-Region` и `X-Language`.
|
||||
|
||||
**Ответ `200`:** Один объект [Item](#объект-item).
|
||||
|
||||
---
|
||||
|
||||
### `GET /searchitems`
|
||||
|
||||
Полнотекстовый поиск по товарам. Учитывает заголовки `X-Region` и `X-Language`.
|
||||
|
||||
**Query-параметры:**
|
||||
|
||||
| Параметр | Тип | По умолч. | Описание |
|
||||
|----------|--------|-----------|-------------------------------|
|
||||
| `search` | string | — | Поисковый запрос (обязателен) |
|
||||
| `count` | number | `50` | Товаров на страницу |
|
||||
| `skip` | number | `0` | Смещение для пагинации |
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{
|
||||
"items": [ /* объекты Item */ ],
|
||||
"total": 128,
|
||||
"count": 50,
|
||||
"skip": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /randomitems`
|
||||
|
||||
Возвращает случайные товары для карусели/рекомендаций. Учитывает заголовки `X-Region` и `X-Language`.
|
||||
|
||||
**Query-параметры:**
|
||||
|
||||
| Параметр | Тип | По умолч. | Описание |
|
||||
|------------|--------|-----------|--------------------------------------|
|
||||
| `count` | number | `5` | Количество товаров |
|
||||
| `category` | number | — | Ограничить данной категорией (опц.) |
|
||||
|
||||
**Ответ `200`:** Массив объектов [Item](#объект-item).
|
||||
|
||||
---
|
||||
|
||||
### Объект Item
|
||||
|
||||
Бэкенд может возвращать товары в **любом** из двух форматов — legacy или BackOffice. Фронтенд нормализует оба варианта.
|
||||
|
||||
```json
|
||||
{
|
||||
"categoryID": 1,
|
||||
"itemID": 123,
|
||||
"name": "iPhone 15 Pro",
|
||||
"photos": [{ "url": "https://..." }],
|
||||
"description": "Описание товара",
|
||||
"currency": "RUB",
|
||||
"price": 89990,
|
||||
"discount": 10,
|
||||
"remainings": "high",
|
||||
"rating": 4.5,
|
||||
"callbacks": [
|
||||
{
|
||||
"rating": 5,
|
||||
"content": "Отличный товар!",
|
||||
"userID": "user_123",
|
||||
"timestamp": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"questions": [
|
||||
{
|
||||
"question": "Есть ли гарантия?",
|
||||
"answer": "Да, 12 месяцев",
|
||||
"upvotes": 5,
|
||||
"downvotes": 0
|
||||
}
|
||||
],
|
||||
|
||||
"id": "item_abc123",
|
||||
"visible": true,
|
||||
"priority": 10,
|
||||
"imgs": ["https://img1.jpg", "https://img2.jpg"],
|
||||
"tags": ["new", "popular"],
|
||||
"badges": ["bestseller", "sale"],
|
||||
"simpleDescription": "Краткое описание",
|
||||
"descriptionFields": [
|
||||
{ "key": "Процессор", "value": "A17 Pro" },
|
||||
{ "key": "Память", "value": "256 GB" }
|
||||
],
|
||||
"subcategoryId": "sub_001",
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Short description",
|
||||
"description": [
|
||||
{ "key": "Processor", "value": "A17 Pro" }
|
||||
]
|
||||
},
|
||||
"hy": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Կարcheck check"
|
||||
}
|
||||
},
|
||||
"comments": [
|
||||
{
|
||||
"id": "cmt_001",
|
||||
"text": "Отличный товар!",
|
||||
"author": "user_123",
|
||||
"stars": 5,
|
||||
"createdAt": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"quantity": 50
|
||||
}
|
||||
```
|
||||
|
||||
**Все поля Item:**
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|---------------------|-------------------|---------|-----------------------------------------------------------|
|
||||
| `categoryID` | number | да | Категория, к которой принадлежит товар |
|
||||
| `itemID` | number | да | Числовой ID товара (legacy) |
|
||||
| `name` | string | да | Название товара |
|
||||
| `photos` | Photo[] | нет | Массив фотографий `[{ url }]` (legacy) |
|
||||
| `description` | string | да | Текстовое описание |
|
||||
| `currency` | string | да | Код валюты (по умолч. `RUB`) |
|
||||
| `price` | number | да | Цена |
|
||||
| `discount` | number | да | Процент скидки (`0`–`100`) |
|
||||
| `remainings` | string | нет | Уровень остатка: `high`, `medium`, `low`, `out` |
|
||||
| `rating` | number | да | Средний рейтинг (`0`–`5`) |
|
||||
| `callbacks` | Review[] | нет | Отзывы (legacy формат) |
|
||||
| `questions` | Question[] | нет | Вопросы и ответы |
|
||||
| `id` | string | нет | Строковый ID из BackOffice |
|
||||
| `visible` | boolean | нет | Виден ли товар (по умолч. `true`) |
|
||||
| `priority` | number | нет | Приоритет сортировки (больше = выше) |
|
||||
| `imgs` | string[] | нет | URL картинок из BackOffice (маппится на `photos`) |
|
||||
| `tags` | string[] | нет | Теги для фильтрации |
|
||||
| `badges` | string[] | нет | Бейджи (`bestseller`, `sale` и т.д.) |
|
||||
| `simpleDescription` | string | нет | Краткое текстовое описание |
|
||||
| `descriptionFields` | DescriptionField[]| нет | Структурированное описание `[{ key, value }]` |
|
||||
| `subcategoryId` | string | нет | Ссылка на подкатегорию из BackOffice |
|
||||
| `translations` | Record | нет | Переводы по ключу языка (см. ниже) |
|
||||
| `comments` | Comment[] | нет | Комментарии в формате BackOffice |
|
||||
| `quantity` | number | нет | Числовое кол-во на складе (маппится на `remainings`) |
|
||||
|
||||
**Вложенные типы:**
|
||||
|
||||
| Тип | Поля |
|
||||
|--------------------|-----------------------------------------------------------------|
|
||||
| `Photo` | `url: string`, `photo?: string`, `video?: string`, `type?: string` |
|
||||
| `DescriptionField` | `key: string`, `value: string` |
|
||||
| `Comment` | `id?: string`, `text: string`, `author?: string`, `stars?: number`, `createdAt?: string` |
|
||||
| `Review` | `rating?: number`, `content?: string`, `userID?: string`, `answer?: string`, `timestamp?: string` |
|
||||
| `Question` | `question: string`, `answer: string`, `upvotes: number`, `downvotes: number` |
|
||||
| `ItemTranslation` | `name?: string`, `simpleDescription?: string`, `description?: DescriptionField[]` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Корзина
|
||||
|
||||
### `POST /cart` — Добавить товар в корзину
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{ "itemID": 123, "quantity": 1 }
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Added to cart" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /cart` — Обновить количество товара
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{ "itemID": 123, "quantity": 3 }
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Updated" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /cart` — Удалить товары из корзины
|
||||
|
||||
**Тело запроса:** Массив ID товаров
|
||||
```json
|
||||
[123, 456]
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Removed" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /cart` — Получить содержимое корзины
|
||||
|
||||
**Ответ `200`:** Массив объектов [Item](#объект-item) (каждый с полем `quantity`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Оплата (СБП / QR)
|
||||
|
||||
### `POST /cart` — Создать платёж (СБП QR)
|
||||
|
||||
> Примечание: Тот же эндпоинт что и добавление в корзину, но с другой схемой тела запроса.
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"amount": 89990,
|
||||
"currency": "RUB",
|
||||
"siteuserID": "tg_123456789",
|
||||
"siteorderID": "order_abc123",
|
||||
"redirectUrl": "",
|
||||
"telegramUsername": "john_doe",
|
||||
"items": [
|
||||
{ "itemID": 123, "price": 89990, "name": "iPhone 15 Pro" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{
|
||||
"qrId": "qr_abc123",
|
||||
"qrStatus": "CREATED",
|
||||
"qrExpirationDate": "2026-02-28T13:00:00Z",
|
||||
"payload": "https://qr.nspk.ru/...",
|
||||
"qrUrl": "https://qr.nspk.ru/..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /qr/payment/:qrId` — Проверить статус оплаты
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{
|
||||
"additionalInfo": "",
|
||||
"paymentPurpose": "Order #order_abc123",
|
||||
"amount": 89990,
|
||||
"code": "SUCCESS",
|
||||
"createDate": "2026-02-28T12:00:00Z",
|
||||
"currency": "RUB",
|
||||
"order": "order_abc123",
|
||||
"paymentStatus": "COMPLETED",
|
||||
"qrId": "qr_abc123",
|
||||
"transactionDate": "2026-02-28T12:01:00Z",
|
||||
"transactionId": 999,
|
||||
"qrExpirationDate": "2026-02-28T13:00:00Z",
|
||||
"phoneNumber": "+7XXXXXXXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
| Значение `paymentStatus` | Значение |
|
||||
|--------------------------|------------------------------|
|
||||
| `CREATED` | QR создан, не оплачен |
|
||||
| `WAITING` | Оплата в процессе |
|
||||
| `COMPLETED` | Оплата успешна |
|
||||
| `EXPIRED` | QR-код истёк |
|
||||
| `CANCELLED` | Оплата отменена |
|
||||
|
||||
---
|
||||
|
||||
### `POST /purchase-email` — Отправить email после оплаты
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"telegramUserId": "123456789",
|
||||
"items": [
|
||||
{ "itemID": 123, "name": "iPhone 15 Pro", "price": 89990, "currency": "RUB" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Email sent" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Отзывы / Комментарии
|
||||
|
||||
### `POST /comment` — Оставить отзыв
|
||||
|
||||
**Тело запроса:**
|
||||
```json
|
||||
{
|
||||
"itemID": 123,
|
||||
"rating": 5,
|
||||
"comment": "Отличный товар!",
|
||||
"username": "john_doe",
|
||||
"userId": 123456789,
|
||||
"timestamp": "2026-02-28T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Review submitted" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Регионы
|
||||
|
||||
### `GET /regions` — Список доступных регионов
|
||||
|
||||
Возвращает регионы, в которых работает маркетплейс.
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "moscow",
|
||||
"city": "Москва",
|
||||
"country": "Россия",
|
||||
"countryCode": "RU",
|
||||
"timezone": "Europe/Moscow"
|
||||
},
|
||||
{
|
||||
"id": "spb",
|
||||
"city": "Санкт-Петербург",
|
||||
"country": "Россия",
|
||||
"countryCode": "RU",
|
||||
"timezone": "Europe/Moscow"
|
||||
},
|
||||
{
|
||||
"id": "yerevan",
|
||||
"city": "Ереван",
|
||||
"country": "Армения",
|
||||
"countryCode": "AM",
|
||||
"timezone": "Asia/Yerevan"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Объект Region:**
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|---------------|--------|---------|----------------------------------|
|
||||
| `id` | string | да | Уникальный идентификатор региона |
|
||||
| `city` | string | да | Название города |
|
||||
| `country` | string | да | Название страны |
|
||||
| `countryCode` | string | да | Код страны ISO 3166-1 alpha-2 |
|
||||
| `timezone` | string | нет | Часовой пояс IANA |
|
||||
|
||||
> **Фоллбэк:** Если эндпоинт недоступен, фронтенд использует 6 захардкоженных значений: Москва, СПб, Ереван, Минск, Алматы, Тбилиси.
|
||||
|
||||
---
|
||||
|
||||
## 8. Авторизация (вход через Telegram)
|
||||
|
||||
Авторизация **через Telegram** с **cookie-сессиями** (HttpOnly, Secure, SameSite=None).
|
||||
|
||||
Все auth-эндпоинты должны поддерживать CORS с `credentials: true`.
|
||||
|
||||
### Процесс авторизации
|
||||
|
||||
```
|
||||
1. Пользователь нажимает «Оформить заказ» → не авторизован → показывается диалог входа
|
||||
2. Нажимает «Войти через Telegram» → открывается https://t.me/{bot}?start=auth_{callback}
|
||||
3. Пользователь запускает бота в Telegram
|
||||
4. Бот отправляет данные пользователя → бэкенд /auth/telegram/callback
|
||||
5. Бэкенд создаёт сессию → устанавливает Set-Cookie
|
||||
6. Фронтенд опрашивает GET /auth/session каждые 3 секунды
|
||||
7. Сессия обнаружена → диалог закрывается → оформление заказа продолжается
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /auth/session` — Проверить текущую сессию
|
||||
|
||||
**Запрос:** Только cookie (сессионная cookie, установленная бэкендом).
|
||||
|
||||
**Ответ `200`** (авторизован):
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
"telegramUserId": 123456789,
|
||||
"username": "john_doe",
|
||||
"displayName": "John Doe",
|
||||
"active": true,
|
||||
"expiresAt": "2026-03-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ `200`** (сессия истекла):
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
"telegramUserId": 123456789,
|
||||
"username": "john_doe",
|
||||
"displayName": "John Doe",
|
||||
"active": false,
|
||||
"expiresAt": "2026-02-27T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ `401`** (нет сессии):
|
||||
```json
|
||||
{ "error": "No active session" }
|
||||
```
|
||||
|
||||
**Объект AuthSession:**
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|------------------|---------|---------|-------------------------------------------|
|
||||
| `sessionId` | string | да | Уникальный ID сессии |
|
||||
| `telegramUserId` | number | да | ID пользователя в Telegram |
|
||||
| `username` | string? | нет | @username в Telegram (может быть null) |
|
||||
| `displayName` | string | да | Отображаемое имя (имя + фамилия) |
|
||||
| `active` | boolean | да | Действительна ли сессия |
|
||||
| `expiresAt` | string | да | Дата истечения в формате ISO 8601 |
|
||||
|
||||
---
|
||||
|
||||
### `GET /auth/telegram/callback` — Callback авторизации Telegram-бота
|
||||
|
||||
Вызывается Telegram-ботом после авторизации пользователя.
|
||||
|
||||
**Тело запроса (от бота):**
|
||||
```json
|
||||
{
|
||||
"id": 123456789,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"username": "john_doe",
|
||||
"photo_url": "https://t.me/i/userpic/...",
|
||||
"auth_date": 1709100000,
|
||||
"hash": "abc123def456..."
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ:** Должен установить cookie сессии и вернуть:
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
"message": "Authenticated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Требования к cookie:**
|
||||
|
||||
| Атрибут | Значение | Примечание |
|
||||
|------------|----------------|-----------------------------------------------------|
|
||||
| `HttpOnly` | `true` | Недоступна из JavaScript |
|
||||
| `Secure` | `true` | Только HTTPS |
|
||||
| `SameSite` | `None` | Обязательно для cross-origin (API ≠ фронтенд) |
|
||||
| `Path` | `/` | |
|
||||
| `Max-Age` | `86400` (24ч) | Или по необходимости |
|
||||
|
||||
---
|
||||
|
||||
### `POST /auth/logout` — Завершить сессию
|
||||
|
||||
**Запрос:** Только cookie, пустое тело `{}`
|
||||
|
||||
**Ответ `200`:**
|
||||
```json
|
||||
{ "message": "Logged out" }
|
||||
```
|
||||
|
||||
Должен очистить/инвалидировать cookie сессии.
|
||||
|
||||
---
|
||||
|
||||
### Обновление сессии
|
||||
|
||||
Фронтенд повторно проверяет сессию за **60 секунд до `expiresAt`**. Если бэкенд поддерживает скользящий срок действия (sliding expiration), можно обновлять `Max-Age` cookie при каждом вызове `GET /auth/session`.
|
||||
|
||||
---
|
||||
|
||||
## 9. i18n / Переводы
|
||||
|
||||
Фронтенд поддерживает 3 языка: **Русский (ru)**, **Английский (en)**, **Армянский (hy)**.
|
||||
|
||||
Активный язык отправляется через HTTP-заголовок `X-Language` с каждым запросом.
|
||||
|
||||
### Что бэкенд должен делать с `X-Language`
|
||||
|
||||
1. **Категории и товары**: Если для запрошенного языка есть поле `translations`, вернуть переведённые `name`, `description` и т.д. ИЛИ бэкенд может применять переводы на стороне сервера и возвращать уже переведённые поля.
|
||||
|
||||
2. **Поле `translations`** на товарах (опциональный подход):
|
||||
```json
|
||||
{
|
||||
"translations": {
|
||||
"en": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Short desc in English",
|
||||
"description": [{ "key": "Processor", "value": "A17 Pro" }]
|
||||
},
|
||||
"hy": {
|
||||
"name": "iPhone 15 Pro",
|
||||
"simpleDescription": "Կarcheck check"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Рекомендуемый подход**: Читать заголовок `X-Language` и возвращать `name`/`description` на этом языке напрямую. Если перевода нет — возвращать русский вариант по умолчанию.
|
||||
|
||||
---
|
||||
|
||||
## 10. Настройка CORS
|
||||
|
||||
Для работы auth-cookie и кастомных заголовков конфигурация CORS бэкенда должна включать:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: https://dexarmarket.ru (НЕ wildcard *)
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Content-Type, X-Region, X-Language
|
||||
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
|
||||
```
|
||||
|
||||
> **Важно:** `Access-Control-Allow-Origin` не может быть `*` при `Allow-Credentials: true`. Должен быть точный origin фронтенда.
|
||||
|
||||
**Разрешённые origins:**
|
||||
- `https://dexarmarket.ru`
|
||||
- `https://novo.market`
|
||||
- `http://localhost:4200` (dev)
|
||||
- `http://localhost:4201` (dev, Novo)
|
||||
|
||||
---
|
||||
|
||||
## 11. Настройка Telegram-бота
|
||||
|
||||
Каждому бренду нужен свой бот:
|
||||
- **Dexar:** `@dexarmarket_bot`
|
||||
- **Novo:** `@novomarket_bot`
|
||||
|
||||
Бот должен:
|
||||
1. Слушать команду `/start auth_{callbackUrl}`
|
||||
2. Извлечь callback URL
|
||||
3. Отправить данные пользователя (`id`, `first_name`, `username` и т.д.) на этот callback URL
|
||||
4. Callback URL: `{apiUrl}/auth/telegram/callback`
|
||||
|
||||
---
|
||||
|
||||
## Полный справочник эндпоинтов
|
||||
|
||||
### Новые эндпоинты
|
||||
|
||||
| Метод | Путь | Описание | Авторизация |
|
||||
|--------|---------------------------|---------------------------------|-------------|
|
||||
| `GET` | `/regions` | Список доступных регионов | Нет |
|
||||
| `GET` | `/auth/session` | Проверка текущей сессии | Cookie |
|
||||
| `GET` | `/auth/telegram/callback` | Callback авторизации через бота | Нет (бот) |
|
||||
| `POST` | `/auth/logout` | Завершение сессии | Cookie |
|
||||
|
||||
### Существующие эндпоинты
|
||||
|
||||
| Метод | Путь | Описание | Авт. | Заголовки |
|
||||
|----------|-----------------------|---------------------------|------|--------------------|
|
||||
| `GET` | `/ping` | Проверка состояния | Нет | — |
|
||||
| `GET` | `/category` | Список категорий | Нет | X-Region, X-Language |
|
||||
| `GET` | `/category/:id` | Товары категории | Нет | X-Region, X-Language |
|
||||
| `GET` | `/item/:id` | Один товар | Нет | X-Region, X-Language |
|
||||
| `GET` | `/searchitems` | Поиск товаров | Нет | X-Region, X-Language |
|
||||
| `GET` | `/randomitems` | Случайные товары | Нет | X-Region, X-Language |
|
||||
| `POST` | `/cart` | Добавить в корзину / Оплата | Нет* | — |
|
||||
| `PATCH` | `/cart` | Обновить кол-во | Нет* | — |
|
||||
| `DELETE` | `/cart` | Удалить из корзины | Нет* | — |
|
||||
| `GET` | `/cart` | Содержимое корзины | Нет* | — |
|
||||
| `POST` | `/comment` | Оставить отзыв | Нет | — |
|
||||
| `GET` | `/qr/payment/:qrId` | Статус оплаты | Нет | — |
|
||||
| `POST` | `/purchase-email` | Отправить email после оплаты | Нет | — |
|
||||
|
||||
> \* Эндпоинты корзины/оплаты могут использовать cookie сессии (если есть) для привязки к заказу, но не требуют авторизации строго. Фронтенд проверяет авторизацию перед оформлением заказа.
|
||||
824
docs/BACKEND_AUTH_INTEGRATION.md
Normal file
824
docs/BACKEND_AUTH_INTEGRATION.md
Normal file
@@ -0,0 +1,824 @@
|
||||
# Авторизация через Telegram — Backend & Bot
|
||||
|
||||
> Всё что нужно Go-разработчику для реализации авторизации.
|
||||
> Фронтенд **полностью готов** и ждёт эти эндпоинты.
|
||||
|
||||
---
|
||||
|
||||
## Статус
|
||||
|
||||
| Компонент | Готов? |
|
||||
|-----------|--------|
|
||||
| Frontend (Angular) — диалог, QR, поллинг, корзина | ✅ Готов |
|
||||
| Telegram бот (обработка `/start`) | ❌ Нужно |
|
||||
| Backend — 6 HTTP-эндпоинтов | ❌ Нужно |
|
||||
| Хранилище сессий + QR-токенов | ❌ Нужно |
|
||||
| CORS для cookie-based запросов | ❌ Нужно |
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
Два сценария авторизации:
|
||||
|
||||
### Сценарий 1: Прямой вход (кнопка "Войти через Telegram")
|
||||
|
||||
Пользователь нажимает кнопку → открывается Telegram → бот выдаёт кнопку "Войти на сайт" → callback ставит cookie → фронтенд поллит `/auth/session`.
|
||||
|
||||
### Сценарий 2: QR-логин с десктопа (основной)
|
||||
|
||||
```
|
||||
ДЕСКТОП БРАУЗЕР СЕРВЕР (Go) TELEGRAM
|
||||
│ │ │
|
||||
│ 1. POST /auth/qr/create │ │
|
||||
│ ─────────────────────────────> │ │
|
||||
│ { token: "abc", url: "..." } │ │
|
||||
│ <───────────────────────────── │ │
|
||||
│ │ │
|
||||
│ 2. Показать QR: │ │
|
||||
│ t.me/Bot?start=login_abc │ │
|
||||
│ │ │
|
||||
│ ПОЛЬЗОВАТЕЛЬ СКАНИРУЕТ ТЕЛЕФОНОМ │
|
||||
│ │ │
|
||||
│ │ 3. /start login_abc │
|
||||
│ │ <────────────────────────│
|
||||
│ │ │
|
||||
│ │ Бот → POST /auth/qr/confirm
|
||||
│ │ Бот → "✅ Вы вошли!" │
|
||||
│ │ ────────────────────────>│
|
||||
│ │ │
|
||||
│ 4. GET /auth/qr/poll?token=abc │ │
|
||||
│ (каждые 3 сек) │ │
|
||||
│ ─────────────────────────────> │ │
|
||||
│ { status: "confirmed", │ │
|
||||
│ session: {...} } │ │
|
||||
│ + Set-Cookie: dx_session=... │ │
|
||||
│ <───────────────────────────── │ │
|
||||
│ │ │
|
||||
│ 5. POST /websession/{sessionId} │ │
|
||||
│ [{ itemID, quantity, ... }] │ ← корзина │
|
||||
│ ─────────────────────────────> │ │
|
||||
│ │ │
|
||||
│ 6. Готово! Авторизован + корзина│ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Бренды и боты
|
||||
|
||||
| Бренд | Username бота | Домен фронтенда | API сервер | Cookie Domain |
|
||||
|-------|---------------|------------------|------------|---------------|
|
||||
| Dexar | `DexarSupport_bot` | `dexarmarket.ru` | `api.dexarmarket.ru:445` | `.dexarmarket.ru` |
|
||||
| Novo | `novomarket_bot` | `novo.market` | `api.novo.market:444` | `.novo.market` |
|
||||
|
||||
Бот создаётся через https://t.me/BotFather → `/newbot`. Сохранить `BOT_TOKEN`.
|
||||
|
||||
---
|
||||
|
||||
## Хранилище
|
||||
|
||||
### Структура: Сессия
|
||||
|
||||
```go
|
||||
type Session struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
TelegramUserID int64 `json:"telegramUserId"`
|
||||
Username *string `json:"username"` // может быть null
|
||||
DisplayName string `json:"displayName"`
|
||||
Active bool `json:"active"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
```
|
||||
|
||||
**TTL:** 24 часа.
|
||||
|
||||
### Структура: QR-токен (одноразовый)
|
||||
|
||||
```go
|
||||
type AuthToken struct {
|
||||
Token string `json:"token"`
|
||||
Status string `json:"status"` // "pending" | "confirmed" | "expired"
|
||||
SessionID string `json:"sessionId"` // заполняется после подтверждения ботом
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
```
|
||||
|
||||
**TTL:** 5 минут.
|
||||
|
||||
### Варианты хранения
|
||||
|
||||
**Redis (рекомендуется):**
|
||||
```go
|
||||
// Сессия
|
||||
redisClient.Set(ctx, "session:"+s.SessionID, json, 24*time.Hour)
|
||||
|
||||
// QR-токен
|
||||
redisClient.Set(ctx, "auth_token:"+t.Token, json, 5*time.Minute)
|
||||
```
|
||||
|
||||
**sync.Map (для MVP):**
|
||||
```go
|
||||
var sessions sync.Map
|
||||
var authTokens sync.Map
|
||||
|
||||
// Очистка устаревших токенов — запустить горутину при старте
|
||||
func cleanupExpiredTokens() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
for range ticker.C {
|
||||
authTokens.Range(func(key, value any) bool {
|
||||
t := value.(AuthToken)
|
||||
if time.Now().After(t.ExpiresAt) {
|
||||
authTokens.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP-эндпоинты
|
||||
|
||||
### 1. `POST /auth/qr/create`
|
||||
|
||||
Фронтенд вызывает при открытии диалога логина. Создаёт одноразовый QR-токен.
|
||||
|
||||
```go
|
||||
func handleQrCreate(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Сгенерировать криптографически безопасный токен
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
http.Error(w, "internal error", 500)
|
||||
return
|
||||
}
|
||||
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes)
|
||||
|
||||
// 2. Определить бота по origin
|
||||
botUsername := getBotForOrigin(r.Header.Get("Origin"))
|
||||
|
||||
// 3. Сохранить токен
|
||||
authToken := AuthToken{
|
||||
Token: token,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
saveAuthToken(authToken)
|
||||
|
||||
// 4. Ответить
|
||||
qrURL := fmt.Sprintf("https://t.me/%s?start=login_%s", botUsername, token)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"token": token,
|
||||
"url": qrURL,
|
||||
})
|
||||
}
|
||||
|
||||
func getBotForOrigin(origin string) string {
|
||||
if strings.Contains(origin, "novo.market") {
|
||||
return "novomarket_bot"
|
||||
}
|
||||
return "DexarSupport_bot"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{ "token": "dG9rZW4tYWJj....", "url": "https://t.me/DexarSupport_bot?start=login_dG9rZW4tYWJj...." }
|
||||
```
|
||||
|
||||
> Telegram ограничивает `start` до 64 символов. `login_` (6) + base64url из 32 байт (43) = 49 ✅
|
||||
|
||||
---
|
||||
|
||||
### 2. `GET /auth/qr/poll?token={token}`
|
||||
|
||||
Фронтенд вызывает каждые 3 секунды. Когда бот подтвердил — возвращает сессию и ставит cookie.
|
||||
|
||||
```go
|
||||
func handleQrPoll(w http.ResponseWriter, r *http.Request) {
|
||||
tokenStr := r.URL.Query().Get("token")
|
||||
if tokenStr == "" {
|
||||
http.Error(w, "missing token", 400)
|
||||
return
|
||||
}
|
||||
|
||||
authToken, ok := getAuthToken(tokenStr)
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "expired"})
|
||||
return
|
||||
}
|
||||
|
||||
switch authToken.Status {
|
||||
case "pending":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "pending"})
|
||||
|
||||
case "confirmed":
|
||||
session, err := getSession(authToken.SessionID)
|
||||
if err != nil {
|
||||
http.Error(w, "session not found", 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Cookie в ДЕСКТОПНЫЙ браузер
|
||||
domain := getDomainForOrigin(r.Header.Get("Origin"))
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "dx_session",
|
||||
Value: session.SessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
MaxAge: 86400,
|
||||
Domain: domain,
|
||||
})
|
||||
|
||||
// Удалить использованный токен
|
||||
deleteAuthToken(tokenStr)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "confirmed",
|
||||
"session": session,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getDomainForOrigin(origin string) string {
|
||||
if strings.Contains(origin, "novo.market") {
|
||||
return ".novo.market"
|
||||
}
|
||||
return ".dexarmarket.ru"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответы:**
|
||||
|
||||
| Статус | JSON |
|
||||
|--------|------|
|
||||
| Ждём | `{ "status": "pending" }` |
|
||||
| Подтверждено | `{ "status": "confirmed", "session": { sessionId, telegramUserId, username, displayName, active, expiresAt } }` + `Set-Cookie` |
|
||||
| Истекло | `{ "status": "expired" }` |
|
||||
|
||||
---
|
||||
|
||||
### 3. `POST /auth/qr/confirm` (внутренний, для бота)
|
||||
|
||||
Бот вызывает когда пользователь отсканировал QR. Привязывает сессию к токену.
|
||||
|
||||
```go
|
||||
func handleQrConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
// Проверить секрет бота
|
||||
if r.Header.Get("X-Bot-Secret") != os.Getenv("BOT_INTERNAL_SECRET") {
|
||||
http.Error(w, "forbidden", 403)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID int64 `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Username string `json:"username"`
|
||||
} `json:"telegram_user"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", 400)
|
||||
return
|
||||
}
|
||||
|
||||
authToken, ok := getAuthToken(req.Token)
|
||||
if !ok || authToken.Status != "pending" {
|
||||
http.Error(w, "token not found or already used", 404)
|
||||
return
|
||||
}
|
||||
|
||||
// Создать сессию
|
||||
displayName := req.User.FirstName
|
||||
if req.User.LastName != "" {
|
||||
displayName += " " + req.User.LastName
|
||||
}
|
||||
var username *string
|
||||
if req.User.Username != "" {
|
||||
username = &req.User.Username
|
||||
}
|
||||
|
||||
session := Session{
|
||||
SessionID: uuid.New().String(),
|
||||
TelegramUserID: req.User.ID,
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Active: true,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
saveSession(session)
|
||||
|
||||
// Привязать сессию к токену
|
||||
authToken.Status = "confirmed"
|
||||
authToken.SessionID = session.SessionID
|
||||
saveAuthToken(*authToken)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
```
|
||||
|
||||
**Запрос от бота:**
|
||||
```json
|
||||
{
|
||||
"token": "dG9rZW4tYWJj...",
|
||||
"telegram_user": {
|
||||
"id": 123456789,
|
||||
"first_name": "Иван",
|
||||
"last_name": "Петров",
|
||||
"username": "ivan_petrov"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `GET /auth/session`
|
||||
|
||||
Фронтенд вызывает для проверки текущей сессии. Читает cookie.
|
||||
|
||||
```go
|
||||
func handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("dx_session")
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := getSession(cookie.Value)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
session.Active = false
|
||||
saveSession(session)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(session)
|
||||
}
|
||||
```
|
||||
|
||||
**Формат ответа (200)** — фронтенд ожидает **точно эти поля**:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"telegramUserId": 123456789,
|
||||
"username": "ivan_petrov",
|
||||
"displayName": "Иван Петров",
|
||||
"active": true,
|
||||
"expiresAt": "2026-03-25T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Обязательно | Примечание |
|
||||
|------|-----|-------------|------------|
|
||||
| `sessionId` | string (UUID) | да | Используется для `/websession/{sessionId}` |
|
||||
| `telegramUserId` | number | да | Telegram user ID |
|
||||
| `username` | string / null | нет | Telegram @username |
|
||||
| `displayName` | string | да | "Имя Фамилия" — показывается в UI |
|
||||
| `active` | boolean | да | `false` = истекла |
|
||||
| `expiresAt` | string (ISO 8601) | да | Фронтенд перепроверяет за 60 сек до |
|
||||
|
||||
**Ошибка:** любой HTTP не-200 → фронтенд считает "не авторизован".
|
||||
|
||||
---
|
||||
|
||||
### 5. `GET /auth/telegram/callback`
|
||||
|
||||
Для прямого входа (по кнопке в Telegram, не через QR). Открывается в браузере.
|
||||
|
||||
```go
|
||||
func handleTelegramCallback(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Error(w, "missing token", 400)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := getSession(token)
|
||||
if err != nil || !session.Active {
|
||||
http.Error(w, "invalid or expired token", 401)
|
||||
return
|
||||
}
|
||||
|
||||
domain := getDomainForOrigin(r.Header.Get("Origin"))
|
||||
if domain == "" {
|
||||
domain = ".dexarmarket.ru" // fallback для прямого перехода
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "dx_session",
|
||||
Value: session.SessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
MaxAge: 86400,
|
||||
Domain: domain,
|
||||
})
|
||||
|
||||
// Редирект на сайт
|
||||
http.Redirect(w, r, "https://dexarmarket.ru", http.StatusFound)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. `POST /auth/logout`
|
||||
|
||||
```go
|
||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("dx_session")
|
||||
if err == nil {
|
||||
deleteSession(cookie.Value)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "dx_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
MaxAge: -1,
|
||||
Domain: ".dexarmarket.ru",
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"message":"ok"}`))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cookie-параметры
|
||||
|
||||
| Параметр | Значение | Почему |
|
||||
|----------|----------|--------|
|
||||
| `Name` | `dx_session` | |
|
||||
| `SameSite` | `None` | Фронтенд на `dexarmarket.ru`, API на `api.dexarmarket.ru:445` — разные origins |
|
||||
| `Secure` | `true` | Обязательно при `SameSite=None` |
|
||||
| `Domain` | `.dexarmarket.ru` | Доступна и на `dexarmarket.ru` и на `api.dexarmarket.ru` |
|
||||
| `HttpOnly` | `true` | Недоступна из JS — защита от XSS |
|
||||
| `MaxAge` | `86400` | 24 часа |
|
||||
|
||||
---
|
||||
|
||||
## CORS
|
||||
|
||||
Фронтенд шлёт `withCredentials: true`. Бэкенд обязан вернуть правильные заголовки.
|
||||
|
||||
```go
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
allowed := map[string]bool{
|
||||
"https://dexarmarket.ru": true,
|
||||
"https://www.dexarmarket.ru": true,
|
||||
"https://novo.market": true,
|
||||
"https://www.novo.market": true,
|
||||
"http://localhost:4200": true,
|
||||
}
|
||||
|
||||
if allowed[origin] {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin) // НЕ "*"
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
}
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(200)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
> **Критично:** `Access-Control-Allow-Origin` не может быть `"*"` при `withCredentials`. Вернуть конкретный origin.
|
||||
|
||||
---
|
||||
|
||||
## Роутинг
|
||||
|
||||
```go
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Существующие
|
||||
mux.HandleFunc("GET /items/{id}", handleGetItem)
|
||||
mux.HandleFunc("GET /category", handleGetCategories)
|
||||
mux.HandleFunc("POST /websession/{id}", handleWebSession)
|
||||
mux.HandleFunc("POST /websession/{id}/qr", handleCreateQR)
|
||||
mux.HandleFunc("GET /websession/{id}/{qrId}", handleCheckPayment)
|
||||
|
||||
// Auth — прямой вход
|
||||
mux.HandleFunc("GET /auth/session", handleGetSession)
|
||||
mux.HandleFunc("GET /auth/telegram/callback", handleTelegramCallback)
|
||||
mux.HandleFunc("POST /auth/logout", handleLogout)
|
||||
|
||||
// Auth — QR-логин
|
||||
mux.HandleFunc("POST /auth/qr/create", handleQrCreate)
|
||||
mux.HandleFunc("GET /auth/qr/poll", handleQrPoll)
|
||||
mux.HandleFunc("POST /auth/qr/confirm", handleQrConfirm)
|
||||
|
||||
handler := corsMiddleware(mux)
|
||||
http.ListenAndServeTLS(":445", "cert.pem", "key.pem", handler)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Telegram-бот
|
||||
|
||||
### Обработчик `/start`
|
||||
|
||||
```go
|
||||
const (
|
||||
confirmURL = "http://localhost:8080/auth/qr/confirm"
|
||||
botInternalSecret = os.Getenv("BOT_INTERNAL_SECRET")
|
||||
)
|
||||
|
||||
func handleStart(update tgbotapi.Update) {
|
||||
text := update.Message.Text
|
||||
user := update.Message.From
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(text, "/start login_"):
|
||||
handleQrLogin(update, user, strings.TrimPrefix(text, "/start login_"))
|
||||
|
||||
case strings.HasPrefix(text, "/start auth"):
|
||||
handleDirectAuth(update, user)
|
||||
|
||||
default:
|
||||
sendWelcome(update)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### QR-логин (основной)
|
||||
|
||||
```go
|
||||
func handleQrLogin(update tgbotapi.Update, user *tgbotapi.User, token string) {
|
||||
reqBody := map[string]interface{}{
|
||||
"token": token,
|
||||
"telegram_user": map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"first_name": user.FirstName,
|
||||
"last_name": user.LastName,
|
||||
"username": user.UserName,
|
||||
},
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
req, _ := http.NewRequest("POST", confirmURL, bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Bot-Secret", botInternalSecret)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
msg := tgbotapi.NewMessage(update.Message.Chat.ID,
|
||||
"❌ Не удалось войти. QR-код мог устареть. Попробуйте обновить страницу и отсканировать новый QR.")
|
||||
bot.Send(msg)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
displayName := buildDisplayName(user)
|
||||
msg := tgbotapi.NewMessage(update.Message.Chat.ID,
|
||||
fmt.Sprintf("✅ Вы вошли на сайт как %s!\n\nМожете вернуться в браузер — страница обновится автоматически.", displayName))
|
||||
bot.Send(msg)
|
||||
}
|
||||
|
||||
func buildDisplayName(user *tgbotapi.User) string {
|
||||
name := user.FirstName
|
||||
if user.LastName != "" {
|
||||
name += " " + user.LastName
|
||||
}
|
||||
return name
|
||||
}
|
||||
```
|
||||
|
||||
### Прямой вход (кнопка, для обратной совместимости)
|
||||
|
||||
```go
|
||||
func handleDirectAuth(update tgbotapi.Update, user *tgbotapi.User) {
|
||||
session := Session{
|
||||
SessionID: uuid.New().String(),
|
||||
TelegramUserID: user.ID,
|
||||
Username: stringPtr(user.UserName),
|
||||
DisplayName: buildDisplayName(user),
|
||||
Active: true,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
saveSession(session)
|
||||
|
||||
callbackURL := "https://api.dexarmarket.ru:445/auth/telegram/callback"
|
||||
loginURL := callbackURL + "?token=" + session.SessionID
|
||||
|
||||
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Нажмите кнопку чтобы войти:")
|
||||
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonURL("🔐 Войти на сайт", loginURL),
|
||||
),
|
||||
)
|
||||
bot.Send(msg)
|
||||
}
|
||||
```
|
||||
|
||||
### Запуск бота (long polling)
|
||||
|
||||
```go
|
||||
func main() {
|
||||
bot, _ := tgbotapi.NewBotAPI(os.Getenv("BOT_TOKEN"))
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
updates := bot.GetUpdatesChan(u)
|
||||
|
||||
for update := range updates {
|
||||
if update.Message != nil && strings.HasPrefix(update.Message.Text, "/start") {
|
||||
handleStart(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Синхронизация корзины
|
||||
|
||||
Сразу после QR-логина фронтенд автоматически отправляет корзину:
|
||||
|
||||
```
|
||||
POST /websession/{sessionId}
|
||||
```
|
||||
|
||||
Тело — массив:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"itemID": 123,
|
||||
"quantity": 2,
|
||||
"colour": "#ff0000",
|
||||
"size": "XL",
|
||||
"price": 1500
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
| Поле | Тип | Примечание |
|
||||
|------|-----|------------|
|
||||
| `itemID` | number | ID товара |
|
||||
| `quantity` | number | Количество |
|
||||
| `colour` | string | CSS hex (`#ff0000`). Бэкенд отдаёт `0xff0000`, фронтенд конвертирует |
|
||||
| `size` | string | `"default"` если размер один |
|
||||
| `price` | number | Финальная цена **с учётом скидки** |
|
||||
|
||||
> Этот эндпоинт (`POST /websession/{id}`) уже существует. Ничего менять не нужно, просто учитывать что он вызывается сразу после успешного логина.
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Криптографический токен
|
||||
```go
|
||||
tokenBytes := make([]byte, 32) // 256 бит
|
||||
crypto/rand.Read(tokenBytes)
|
||||
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes)
|
||||
```
|
||||
**НЕ использовать:** `math/rand`, UUID, timestamp.
|
||||
|
||||
### Токен одноразовый
|
||||
- После `confirmed` → удалить при первом успешном `poll`
|
||||
- После 5 минут → автоудаление (TTL)
|
||||
- Повторный `poll` → `"expired"`
|
||||
|
||||
### Защита `/auth/qr/confirm`
|
||||
```go
|
||||
if r.Header.Get("X-Bot-Secret") != os.Getenv("BOT_INTERNAL_SECRET") {
|
||||
http.Error(w, "forbidden", 403)
|
||||
return
|
||||
}
|
||||
```
|
||||
Дополнительно: можно ограничить по IP (`127.0.0.1`) если бот на том же сервере.
|
||||
|
||||
### Rate limiting для `/auth/qr/create`
|
||||
Не более **5 токенов в минуту** с одного IP:
|
||||
```go
|
||||
var ipCounts sync.Map
|
||||
|
||||
func rateLimitQrCreate(ip string) bool {
|
||||
key := ip + ":" + time.Now().Format("2006-01-02T15:04")
|
||||
val, _ := ipCounts.LoadOrStore(key, new(int32))
|
||||
count := atomic.AddInt32(val.(*int32), 1)
|
||||
return count <= 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
```env
|
||||
BOT_TOKEN=123456:ABC-DEF...
|
||||
BOT_INTERNAL_SECRET=случайная-строка-минимум-32-символа
|
||||
FRONTEND_URL=https://dexarmarket.ru
|
||||
SESSION_TTL=24h
|
||||
REDIS_URL=localhost:6379
|
||||
```
|
||||
|
||||
`BOT_INTERNAL_SECRET` должен совпадать в env сервера и env бота.
|
||||
|
||||
---
|
||||
|
||||
## Тестирование
|
||||
|
||||
### curl-тесты
|
||||
|
||||
**1. Создание токена:**
|
||||
```bash
|
||||
curl -X POST https://api.dexarmarket.ru:445/auth/qr/create \
|
||||
-H "Origin: https://dexarmarket.ru"
|
||||
# → { "token": "dG9r...", "url": "https://t.me/DexarSupport_bot?start=login_dG9r..." }
|
||||
```
|
||||
|
||||
**2. Поллинг (до подтверждения):**
|
||||
```bash
|
||||
curl "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..."
|
||||
# → { "status": "pending" }
|
||||
```
|
||||
|
||||
**3. Подтверждение (имитация бота):**
|
||||
```bash
|
||||
curl -X POST https://api.dexarmarket.ru:445/auth/qr/confirm \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Bot-Secret: ваш-секрет" \
|
||||
-d '{"token":"dG9r...","telegram_user":{"id":123,"first_name":"Тест","last_name":"","username":"testuser"}}'
|
||||
# → { "status": "ok" }
|
||||
```
|
||||
|
||||
**4. Поллинг (после подтверждения):**
|
||||
```bash
|
||||
curl -v "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..."
|
||||
# → { "status": "confirmed", "session": {...} } + Set-Cookie: dx_session=...
|
||||
```
|
||||
|
||||
**5. E2E:**
|
||||
1. Открыть маркетплейс → добавить товар в корзину
|
||||
2. Нажать "Оформить заказ" → появляется диалог с QR
|
||||
3. Отсканировать QR телефоном → Telegram → бот: "✅ Вы вошли!"
|
||||
4. Через 3 сек диалог закрывается → авторизован
|
||||
5. Корзина синхронизирована (`POST /websession/{sessionId}`)
|
||||
|
||||
### Отладка
|
||||
|
||||
| Проблема | Где смотреть |
|
||||
|----------|-------------|
|
||||
| QR не показывается | `POST /auth/qr/create` — ошибка? CORS? |
|
||||
| QR отсканирован, ничего не происходит | Бот получил `/start login_...`? Бот вызвал `confirm`? |
|
||||
| Бот пишет "❌ QR устарел" | Токен expired? 5 минут прошло? |
|
||||
| Поллинг "pending" бесконечно | Бот не вызвал `confirm`. Логи бота |
|
||||
| Поллинг "confirmed" но cookie нет | `SameSite`, `Secure`, `Domain`, CORS |
|
||||
|
||||
---
|
||||
|
||||
## Чеклист
|
||||
|
||||
### Бэкенд (Go)
|
||||
|
||||
- [ ] Структура `Session` + `AuthToken`, функции save/get/delete
|
||||
- [ ] `POST /auth/qr/create` — генерация токена
|
||||
- [ ] `GET /auth/qr/poll?token=...` — статус + cookie при confirmed
|
||||
- [ ] `POST /auth/qr/confirm` — приём от бота с `X-Bot-Secret`
|
||||
- [ ] `GET /auth/session` — чтение cookie, JSON сессии
|
||||
- [ ] `GET /auth/telegram/callback?token=...` — cookie + редирект
|
||||
- [ ] `POST /auth/logout` — удаление сессии и cookie
|
||||
- [ ] TTL 5 мин для токенов, 24ч для сессий
|
||||
- [ ] Rate limiting `/auth/qr/create` (5/мин/IP)
|
||||
- [ ] Очистка устаревших токенов
|
||||
- [ ] CORS middleware
|
||||
- [ ] `BOT_INTERNAL_SECRET` в env
|
||||
|
||||
### Telegram бот
|
||||
|
||||
- [ ] Обработка `/start login_{token}` → `POST /auth/qr/confirm`
|
||||
- [ ] Обработка `/start auth` → создание сессии + кнопка "Войти"
|
||||
- [ ] Сообщения: "✅ Вы вошли" / "❌ QR устарел"
|
||||
- [ ] `BOT_INTERNAL_SECRET` в env (совпадает с сервером)
|
||||
- [ ] `BOT_TOKEN` в env
|
||||
@@ -1,11 +1,748 @@
|
||||
bro we need to do changes, that client required
|
||||
1. we need to add location logic
|
||||
1.1 the catalogs will come or for global or for exact region
|
||||
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?
|
||||
2. we need to add somekind of user login logic
|
||||
2.1 user can add to cart, look the items and etc without logged in, but when he is going to buy/pay ->
|
||||
at first he have to login with telegram, i will send you the bots adress.
|
||||
2.1.1 if is not logged -> will see the QR or link for logging via telegram
|
||||
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
|
||||
2.2 and when user is logged, that time he can do a payment
|
||||
General Information
|
||||
Information exchange with the SBP server is realized via RESTful API. All requests to the server must be executed via HTTPS using GET||POST||PUT||DELETE requests to the given ROOT address. Body of requests must be in JSON format. All not public requests must be signed by the client and the public key must be sent to the server for client identification and sign checking.
|
||||
Header:
|
||||
“Authorization”: {JSON WITH KEY AND PARTNERID}
|
||||
“X-Region” : Moscow | Yerevan | ST. Petersburg
|
||||
“X-Language” : RU | AM | EN
|
||||
“WebSessionID” : f02fe5d6-c6ae-4b2e-9b4d-687534e11b01
|
||||
“Currency” :RUB | AMD | USD
|
||||
Root:
|
||||
API.dexarmarket.ru
|
||||
|
||||
|
||||
General Information
|
||||
Check if server is available
|
||||
Get Marketplaces
|
||||
Set Marketplaces
|
||||
Get Item
|
||||
Delete Item
|
||||
New Item
|
||||
New Callback
|
||||
New Question
|
||||
Get random Items
|
||||
Get items in category
|
||||
Get searched items
|
||||
Get Categories
|
||||
Delete Category
|
||||
New Category
|
||||
Create new websession
|
||||
Check websession status
|
||||
Delete websession status
|
||||
Add to cart
|
||||
Create New QR code for cart checkout
|
||||
Check QR code
|
||||
item structure
|
||||
category structure
|
||||
Check if server is available
|
||||
Client needs to periodically check if the server is available by sending “ping” to the client. On error corresponding message must be shown.
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /ping
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response (Error):
|
||||
{
|
||||
"message": "pong",
|
||||
"status": "Wrong Header"
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"message": "pong",
|
||||
"status": "Correct Header"
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get Marketplaces
|
||||
Get Available Marketplaces
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /marketplaces
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
[{“brand” : “dexar”,
|
||||
“api”:”dexar.market”,
|
||||
“bot”:”dexarmarket_bot”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”, ”Yerevan - Armenia”]
|
||||
“currency”:[“RUB, ”AMD”, ”USD”]
|
||||
“icon”:”./dexar.market.png”},
|
||||
{“brand” : “store”,
|
||||
“api”:”dexarmarket.store”,
|
||||
“bot”:”dexarstore_bot”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./dexarmarket.store.png”},
|
||||
{“brand” : “Novo”,
|
||||
“api”:”novo.market”,
|
||||
“bot”:”novomarket_bot”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./novo.market.png”}]
|
||||
|
||||
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Set Marketplaces
|
||||
Get Available Marketplaces
|
||||
Protocol: https
|
||||
Type: PUT
|
||||
Path: /marketplaces
|
||||
Request Parameters:
|
||||
{
|
||||
[{“brand” : “dexar”,
|
||||
“api”:”dexar.market”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./dexar.market.png”},
|
||||
{“brand” : “store”,
|
||||
“api”:”dexarmarket.store”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./dexarmarket.store.png”},
|
||||
{“brand” : “Novo”,
|
||||
“api”:”novo.market”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./novo.market.png”}]
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Marketplace updated”
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get Item
|
||||
Get Item by ID
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
________________
|
||||
Delete Item
|
||||
Delete the item
|
||||
Protocol: https
|
||||
Type: Delete
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Item was deleted”
|
||||
}
|
||||
________________
|
||||
New Item
|
||||
Create new Item
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
________________
|
||||
Update Item
|
||||
Update the item
|
||||
Protocol: https
|
||||
Type: PUT
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Item updated”
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
New Callback
|
||||
Update the item
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /items/:itemID/callback
|
||||
Request Parameters:
|
||||
{
|
||||
"rating": 5,
|
||||
"comment": "Отличный товар!",
|
||||
"sessionID": “ f02fe5d6-c6ae-4b2e-9b4d-687534e11b01”
|
||||
"timestamp": "2026-02-28T12:00:00Z"
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong item"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Callback added”
|
||||
}
|
||||
________________
|
||||
New Question
|
||||
Update the item
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /items/:itemID/questiion
|
||||
Request Parameters:
|
||||
{
|
||||
"question": "some question!",
|
||||
"sessionID": “ f02fe5d6-c6ae-4b2e-9b4d-687534e11b01”
|
||||
"timestamp": "2026-02-28T12:00:00Z"
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong item"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Questiion added”
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get random Items
|
||||
Get given number of items from random categorues
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /items/randomitems?count=15 // 20 is the default
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
[“itemID”:...]
|
||||
}
|
||||
________________
|
||||
Get items in category
|
||||
Get all items in category and in all subcategories inside the category
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /category/:categoryID?count=30, skip=60 // default skip=0, default count=20
|
||||
|
||||
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
[“itemID”:...]
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get searched items
|
||||
Get all items in category and in all subcategories inside the category
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /searchitems
|
||||
Parameters:
|
||||
{
|
||||
search (string) — query text
|
||||
categoryIDs (string) — e.g., 1,2,5 (includes all subcategories)
|
||||
minPrice / maxPrice (float) — price range
|
||||
tag (string) — e.g., sale
|
||||
sort (string) — relevance (default), price_asc, price_desc, popular, rating
|
||||
skip / count — default 0 / 20
|
||||
}
|
||||
Examples:
|
||||
* ?search=iphone&sort=popular
|
||||
* ?categoryIDs=1,5&minPrice=100&maxPrice=500
|
||||
* ?tag=new&sort=price_asc&count=10
|
||||
|
||||
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
"total": 12,
|
||||
"skip": 0,
|
||||
"count": 12,
|
||||
"isGlobal": false,
|
||||
"items": [
|
||||
{ "itemID": 101, "name": "..." }
|
||||
]
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get Categories
|
||||
Get all available categories
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /category
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“categoryID”:...
|
||||
}
|
||||
________________
|
||||
Delete Category
|
||||
Delete EMPTY category, no items and no subcategories must present
|
||||
Protocol: https
|
||||
Type: Delete
|
||||
Path: /category/:categoryID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Category was deleted”
|
||||
}
|
||||
________________
|
||||
New Category
|
||||
Create new category
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /category/
|
||||
Request Parameters:
|
||||
{
|
||||
“CategoryID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“CategoryID”:...
|
||||
}
|
||||
________________
|
||||
Update Category
|
||||
Update existing category
|
||||
Protocol: https
|
||||
Type: PUT
|
||||
Path: /category/:categoryID
|
||||
Request Parameters:
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Category was updated”
|
||||
}
|
||||
________________
|
||||
Create new websession
|
||||
Creates a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
|
||||
Protocol: https
|
||||
Type POST
|
||||
Path /websession
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||
"userId" : "",
|
||||
"expires" : "sessionId",
|
||||
"userSessionId": "",
|
||||
"status": false
|
||||
}
|
||||
________________
|
||||
Check websession status
|
||||
Check if the user is already logged in. a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
|
||||
Protocol: https
|
||||
Type GET
|
||||
Path /websession/:webSessionID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
|
||||
"expires" : "sessionId",
|
||||
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
|
||||
"x-Region" : "Moscow",
|
||||
"x-Language" : "RU",
|
||||
"currency" : "RUB",
|
||||
"Status": true,
|
||||
"cart": [
|
||||
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||
]
|
||||
}
|
||||
________________
|
||||
Delete websession status
|
||||
Delete the session to log out from the system.
|
||||
Protocol: https
|
||||
Type DELETE
|
||||
Path /websession/:webSessionID
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
“status”:”User logged out”
|
||||
}
|
||||
|
||||
________________
|
||||
Add to cart
|
||||
Add a all item to users (session) cart
|
||||
Protocol: https
|
||||
Type Post
|
||||
Path /websession/:webSessionID
|
||||
Request Parameters:
|
||||
{
|
||||
[
|
||||
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||
]
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
|
||||
"expires" : "sessionId",
|
||||
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
|
||||
"Status": true,
|
||||
"cart": [
|
||||
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||
]
|
||||
}
|
||||
________________
|
||||
Create New QR code for cart checkout
|
||||
Create New QR for payment via SBP
|
||||
Protocol: https
|
||||
Type POST
|
||||
Path /websession/:webSessionID/qr
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong key"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
"qrId": "BD10002CI1V3JP1T8QR8TIQ8K35RBVQB",
|
||||
"qrStatus": "NEW",
|
||||
"qrExpirationDate": "2025-11-20T10:10:44Z",
|
||||
"Payload": "https://qr.nspk.ru/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB?type=02&bank=100000000007&sum=1000&cur=RUB&crc=8ACC",
|
||||
"qrUrl": "https://e-commerce.raiffeisen.ru/api/sbp/v1/qr/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB/image"
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Check QR code
|
||||
Check QR status
|
||||
Protocol: https
|
||||
Type GET
|
||||
Path /websession/:webSessionID/:qrID
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "Error from the bank "
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
"additionalInfo": "",
|
||||
"paymentPurpose": "",
|
||||
"amount": 10,
|
||||
"code": "SUCCESS",
|
||||
"createDate": "2025-11-20T13:17:20.453884+03:00",
|
||||
"currency": "RUB",
|
||||
"order": "102_540",
|
||||
"paymentStatus": "NO_INFO", //check for SUCCESS
|
||||
"qrId": "BD1000263VS7G81D8JCP5FHFTFEH38MT",
|
||||
"transactionDate": "",
|
||||
"transactionId": 0,
|
||||
"qrExpirationDate": "2025-11-20T13:32:20+03:00"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 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..."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
Бот должен:
|
||||
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 |
|
||||
|
||||
|
||||
________________
|
||||
|
||||
|
||||
item structure
|
||||
CategoryID uint64 `json:"categoryID" binding:"required"`
|
||||
ItemID uint64 `json:"itemID" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Discount float32 `json:"discount" `
|
||||
Rating float32 `json:"rating" binding:"required"`
|
||||
Visible bool `json:"rating"`
|
||||
Priority uint64 `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
Badges []string `json:"badges"`
|
||||
Details []itemdetail `json:"itemdetails"`
|
||||
Colour string `json:"colour" binding:"required"`
|
||||
Size string `json:"size" binding:"required"`
|
||||
Price float32 `json:"price" binding:"required"`
|
||||
Currency string `json:"currency" binding:"required"`
|
||||
Remaining uint64 `json:"remaining" binding:"required"`
|
||||
Names []itemname `json:"names"`
|
||||
Language string `json:"language"`
|
||||
Value string `json:"value"`
|
||||
Descriptions []itemdescription `json:"descriptions" `
|
||||
Language string `json:"language"`
|
||||
Value string `json:"value"`
|
||||
Attributes []attribute `json:"attributes" binding:"required"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Photos []photo `json:"photos"`
|
||||
Type string `json:"type" binding:"required"` //video || photo
|
||||
URL string `json:"url" binding:"required"`
|
||||
Questions []question `json:"questions"`
|
||||
Question string `json:"question" `
|
||||
Answer string `json:"answer" `
|
||||
Like uint64 `json:"like" `
|
||||
Dislike uint64 `json:"dislike" `
|
||||
Visits uint64 `json:"visits"`
|
||||
Callbacks []callback `json:"callbacks" binding:"required"`
|
||||
Rating float32 `json:"rating,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Userid string `json:"userID"`
|
||||
Answer string `json:"answer"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
PartnerID []string `json:"partnerID" binding:"required"`
|
||||
|
||||
|
||||
category structure
|
||||
CategoryID uint64 `json:"categoryID" binding:"required"`
|
||||
ParentID uint64 `json:"parentID" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Visible bool `json:"visible" `
|
||||
Priority uint64 `json:"priority" `
|
||||
Icon string `json:"icon"`
|
||||
WideIcon string `json:"wideicon"`
|
||||
ItemsCount uint64
|
||||
CategoriesCount uint64
|
||||
Names []itemname `json:"names"`
|
||||
Language string `json:"language"`
|
||||
Value string `json:"value"`
|
||||
@@ -36,6 +36,9 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
|
||||
|
||||
# Brotli compression (if available)
|
||||
# brotli on;
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
{
|
||||
"name": "api-cache",
|
||||
"urls": [
|
||||
"/api/**"
|
||||
"/api/**",
|
||||
"https://api.dexarmarket.ru:445/**",
|
||||
"https://api.novo.market:444/**"
|
||||
],
|
||||
"cacheConfig": {
|
||||
"maxSize": 100,
|
||||
|
||||
73
package-lock.json
generated
73
package-lock.json
generated
@@ -8,11 +8,15 @@
|
||||
"name": "dexarmarket",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.1.5",
|
||||
"@angular/cdk": "^21.1.5",
|
||||
"@angular/common": "^21.0.6",
|
||||
"@angular/compiler": "^21.0.6",
|
||||
"@angular/core": "^21.0.6",
|
||||
"@angular/forms": "^21.0.6",
|
||||
"@angular/material": "^21.1.5",
|
||||
"@angular/platform-browser": "^21.0.6",
|
||||
"@angular/platform-browser-dynamic": "^21.1.5",
|
||||
"@angular/router": "^21.0.6",
|
||||
"@angular/service-worker": "^21.0.6",
|
||||
"primeicons": "^7.0.0",
|
||||
@@ -324,6 +328,21 @@
|
||||
"yarn": ">= 1.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/animations": {
|
||||
"version": "21.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.1.5.tgz",
|
||||
"integrity": "sha512-gsqHX8lCYV8cgVtHs0iLwrX8SVlmcjUF44l/xCc/jBC/TeKWRl2e6Jqrn1Wcd0NDlGiNsm+mYNyqMyy5/I7kjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "21.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/build": {
|
||||
"version": "21.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.0.tgz",
|
||||
@@ -472,6 +491,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@angular/cdk": {
|
||||
"version": "21.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.5.tgz",
|
||||
"integrity": "sha512-AlQPgqe3LLwXCyrDwYSX3m/WKnl2ppCMW7Gb+7bJpIcpMdWYEpSOSQF318jXGYIysKg43YbdJ1tWhJWY/cbn3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^8.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^21.0.0 || ^22.0.0",
|
||||
"@angular/core": "^21.0.0 || ^22.0.0",
|
||||
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli": {
|
||||
"version": "21.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.0.tgz",
|
||||
@@ -613,6 +648,23 @@
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/material": {
|
||||
"version": "21.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/material/-/material-21.1.5.tgz",
|
||||
"integrity": "sha512-D6JvFulPvIKhPJ52prMV7DxwYMzcUpHar11ZcMb7r9WQzUfCS3FDPXfMAce5n3h+3kFccfmmGpnyBwqTlLPSig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/cdk": "21.1.5",
|
||||
"@angular/common": "^21.0.0 || ^22.0.0",
|
||||
"@angular/core": "^21.0.0 || ^22.0.0",
|
||||
"@angular/forms": "^21.0.0 || ^22.0.0",
|
||||
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "21.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.6.tgz",
|
||||
@@ -635,6 +687,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser-dynamic": {
|
||||
"version": "21.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.1.5.tgz",
|
||||
"integrity": "sha512-Pd8nPbJSIONnze1WS9wLBAtaFw4TYIH+ZGjKHS9G1E9l09tDWtHWyB7dY82Sc//Nc8iR4V7dcsbUmFjOJHThww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "21.1.5",
|
||||
"@angular/compiler": "21.1.5",
|
||||
"@angular/core": "21.1.5",
|
||||
"@angular/platform-browser": "21.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "21.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.6.tgz",
|
||||
@@ -7687,7 +7757,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
@@ -7741,7 +7810,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -9512,3 +9580,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"dexar": "ng serve --configuration=development --port 4200",
|
||||
"novo": "ng serve --configuration=novo --port 4201",
|
||||
"novo": "ng serve --configuration=novo --port 4201 --proxy-config proxy.conf.novo.json",
|
||||
"start:dexar": "ng serve --configuration=development --port 4200",
|
||||
"start:novo": "ng serve --configuration=novo --port 4201",
|
||||
"build": "ng build",
|
||||
@@ -16,11 +16,15 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.1.5",
|
||||
"@angular/cdk": "^21.1.5",
|
||||
"@angular/common": "^21.0.6",
|
||||
"@angular/compiler": "^21.0.6",
|
||||
"@angular/core": "^21.0.6",
|
||||
"@angular/forms": "^21.0.6",
|
||||
"@angular/material": "^21.1.5",
|
||||
"@angular/platform-browser": "^21.0.6",
|
||||
"@angular/platform-browser-dynamic": "^21.1.5",
|
||||
"@angular/router": "^21.0.6",
|
||||
"@angular/service-worker": "^21.0.6",
|
||||
"primeicons": "^7.0.0",
|
||||
@@ -43,3 +47,4 @@
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
proxy.conf.novo.json
Normal file
11
proxy.conf.novo.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "https://api.novo.market:444",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"pathRewrite": {
|
||||
"^/api": ""
|
||||
},
|
||||
"logLevel": "debug"
|
||||
}
|
||||
}
|
||||
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||
"name": "Novo Market - Интернет-магазин",
|
||||
"short_name": "Novo",
|
||||
"description": "Novo Market - ваш онлайн магазин качественных товаров с доставкой",
|
||||
@@ -12,34 +11,10 @@
|
||||
"categories": ["shopping", "lifestyle"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
"src": "assets/images/novo-favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
@@ -47,12 +22,6 @@
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
|
||||
@@ -11,34 +11,10 @@
|
||||
"categories": ["shopping", "marketplace"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
"src": "assets/images/dexar-favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
@@ -46,12 +22,6 @@
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
|
||||
@@ -4,6 +4,8 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { cacheInterceptor } from './interceptors/cache.interceptor';
|
||||
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
|
||||
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
@@ -15,7 +17,7 @@ export const appConfig: ApplicationConfig = {
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'top' })
|
||||
),
|
||||
provideHttpClient(
|
||||
withInterceptors([cacheInterceptor])
|
||||
withInterceptors([mockDataInterceptor, apiHeadersInterceptor, cacheInterceptor])
|
||||
),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
</div>
|
||||
} @else {
|
||||
<app-header></app-header>
|
||||
@if (!isHomePage()) {
|
||||
<app-back-button />
|
||||
}
|
||||
<main class="main-content">
|
||||
@if (!isHomePage()) {
|
||||
<app-back-button />
|
||||
}
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-footer></app-footer>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter([])]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -91,7 +91,7 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Contact Us</h3>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">We are always in touch</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/about/about.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AboutNovoEnComponent {}
|
||||
export class AboutNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Կապվել մեզ հետ</h3>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">Մենք միշտ կապի մեջ ենք</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/about/about.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AboutNovoHyComponent {}
|
||||
export class AboutNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Связаться с нами</h3>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">Мы всегда на связи</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/about/about.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AboutNovoRuComponent {}
|
||||
export class AboutNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="info-card">
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Phone</h3>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contacts-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/contacts/contacts.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ContactsNovoEnComponent {}
|
||||
export class ContactsNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="info-card">
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Հեռախոս</h3>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contacts-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/contacts/contacts.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ContactsNovoHyComponent {}
|
||||
export class ContactsNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="info-card">
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Телефон</h3>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contacts-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/contacts/contacts.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ContactsNovoRuComponent {}
|
||||
export class ContactsNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<h3>Questions about delivery?</h3>
|
||||
<p>Contact the seller or us:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/delivery/delivery.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DeliveryNovoEnComponent {}
|
||||
export class DeliveryNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<h3>Առաքման հարցեր՞</h3>
|
||||
<p>Կապվեք վաճառողի կամ մեզ հետ՝</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/delivery/delivery.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DeliveryNovoHyComponent {}
|
||||
export class DeliveryNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<h3>Вопросы по доставке?</h3>
|
||||
<p>Свяжитесь с продавцом или нами:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/delivery/delivery.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DeliveryNovoRuComponent {}
|
||||
export class DeliveryNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Phone</strong>
|
||||
<a href="tel:+37498731231">+374 98 731231</a>
|
||||
<a [href]="env.phoneTel">{{ env.phones.support }}</a>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Working Hours</strong>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-faq-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/faq/faq.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FaqNovoEnComponent {}
|
||||
export class FaqNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Հեռախոս</strong>
|
||||
<a href="tel:+37498731231">+374 98 731231</a>
|
||||
<a [href]="env.phoneTel">{{ env.phones.support }}</a>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Աշխատանքային ժամեր</strong>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-faq-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/faq/faq.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FaqNovoHyComponent {}
|
||||
export class FaqNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Телефон</strong>
|
||||
<a href="tel:+37498731231">+374 98 731231</a>
|
||||
<a [href]="env.phoneTel">{{ env.phones.support }}</a>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Время работы</strong>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-faq-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/faq/faq.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FaqNovoRuComponent {}
|
||||
export class FaqNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<h3>Need Help?</h3>
|
||||
<p>In case of disputes:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="note">Subject: "Warranty Issue - Order #..."</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guarantee-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/guarantee/guarantee.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class GuaranteeNovoEnComponent {}
|
||||
export class GuaranteeNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<h3>Օգնությու՞ն պետք է՞</h3>
|
||||
<p>Վեճերի դեպքում՝</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="note">Թեմա՝ “Երաշխիքային հարց - Պատվեր №...”</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guarantee-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/guarantee/guarantee.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class GuaranteeNovoHyComponent {}
|
||||
export class GuaranteeNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<h3>Нужна помощь?</h3>
|
||||
<p>При возникновении споров:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="note">Тема: "Гарантийный вопрос - Заказ №..."</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guarantee-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/guarantee/guarantee.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class GuaranteeNovoRuComponent {}
|
||||
export class GuaranteeNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Contact Us</h3>
|
||||
<div class="contacts-grid">
|
||||
<a href="tel:+37498731231" class="contact-link">
|
||||
<a [href]="env.phoneTel" class="contact-link">
|
||||
<span class="contact-icon">📱</span>
|
||||
<div>
|
||||
<div class="contact-label">Phone</div>
|
||||
<div class="contact-value">+374 98 731231</div>
|
||||
<div class="contact-value">{{ env.phones.support }}</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="mailto:info@novo.market" class="contact-link">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-company-details-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/company-details/company-details.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CompanyDetailsNovoEnComponent {}
|
||||
export class CompanyDetailsNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Կապվեք մեզ հետ</h3>
|
||||
<div class="contacts-grid">
|
||||
<a href="tel:+37498731231" class="contact-link">
|
||||
<a [href]="env.phoneTel" class="contact-link">
|
||||
<span class="contact-icon">📱</span>
|
||||
<div>
|
||||
<div class="contact-label">Հեռախոս</div>
|
||||
<div class="contact-value">+374 98 731231</div>
|
||||
<div class="contact-value">{{ env.phones.support }}</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="mailto:info@novo.market" class="contact-link">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-company-details-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/company-details/company-details.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CompanyDetailsNovoHyComponent {}
|
||||
export class CompanyDetailsNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Связаться с нами</h3>
|
||||
<div class="contacts-grid">
|
||||
<a href="tel:+37498731231" class="contact-link">
|
||||
<a [href]="env.phoneTel" class="contact-link">
|
||||
<span class="contact-icon">📱</span>
|
||||
<div>
|
||||
<div class="contact-label">Телефон</div>
|
||||
<div class="contact-value">+374 98 731231</div>
|
||||
<div class="contact-value">{{ env.phones.support }}</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="mailto:info@novo.market" class="contact-link">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-company-details-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/company-details/company-details.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CompanyDetailsNovoRuComponent {}
|
||||
export class CompanyDetailsNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<p>For questions related to order payments, you can contact us:</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>Email:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Phone:</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Phone:</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Working hours:</strong> 24/7 (technical support)</li>
|
||||
<li><strong>Average response time:</strong> Up to 24 hours on business days</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/payment-terms/payment-terms.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PaymentTermsNovoEnComponent {}
|
||||
export class PaymentTermsNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<p>Պատվերների վճարման հետ կապված հարցերի համար կարող եք դիմել՝</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>Email:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Հեռախոս՝</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Հեռախոս՝</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Աշխատանքի ժամերը՝</strong> Հստակետ (տեխնիկական աջակցություն)</li>
|
||||
<li><strong>Պատասխանի միջին ժամանակը՝</strong> Մինչև 24 ժամ աշխատանքային օրերին</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/payment-terms/payment-terms.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PaymentTermsNovoHyComponent {}
|
||||
export class PaymentTermsNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<p>По вопросам, связанным с оплатой заказов, вы можете обратиться:</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>Email:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Телефон:</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Телефон:</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Время работы:</strong> Круглосуточно (техническая поддержка)</li>
|
||||
<li><strong>Среднее время ответа:</strong> До 24 часов в рабочие дни</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/payment-terms/payment-terms.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PaymentTermsNovoRuComponent {}
|
||||
export class PaymentTermsNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
<h3>Contact Information</h3>
|
||||
<p>For all questions regarding personal data processing, please contact:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="note">We will respond within 30 days in accordance with the legislation of the Russian Federation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-privacy-policy-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/privacy-policy/privacy-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PrivacyPolicyNovoEnComponent {}
|
||||
export class PrivacyPolicyNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-privacy-policy-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/privacy-policy/privacy-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PrivacyPolicyNovoHyComponent {}
|
||||
export class PrivacyPolicyNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
<h3>Контакты для связи</h3>
|
||||
<p>По всем вопросам обработки персональных данных обращайтесь:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="note">Мы ответим в течение 30 дней согласно законодательству РФ</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-privacy-policy-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/privacy-policy/privacy-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PrivacyPolicyNovoRuComponent {}
|
||||
export class PrivacyPolicyNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
<h2>Contact Us</h2>
|
||||
<p>Questions about the agreement:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">We are always happy to help</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/public-offer/public-offer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PublicOfferNovoEnComponent {}
|
||||
export class PublicOfferNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
<h2>Կապ</h2>
|
||||
<p>Համաձայնագրի վերաբերյալ հարցեր՝</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">Մենք միշտ պատրաստ ենք օգնելու</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/public-offer/public-offer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PublicOfferNovoHyComponent {}
|
||||
export class PublicOfferNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
<h2>Контакты</h2>
|
||||
<p>Вопросы по соглашению:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">Мы всегда готовы помочь</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/public-offer/public-offer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PublicOfferNovoRuComponent {}
|
||||
export class PublicOfferNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<p>To resolve disputes through the platform administration:</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>E-mail:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Phone:</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Phone:</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Subject:</strong> "Dispute with seller — Order #"</li>
|
||||
<li><strong>Attach:</strong> correspondence, photos, payment receipt</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-return-policy-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/return-policy/return-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ReturnPolicyNovoEnComponent {}
|
||||
export class ReturnPolicyNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<p>Վեճերը հարթակի ադմինիստրացիայի միջոցով լուծելու համար՝</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>E-mail:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Հեռախոս՝</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Հեռախոս՝</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Թեմա՝</strong> «Վեճ վաճառողի հետ — Պատվերի №»</li>
|
||||
<li><strong>Կցեք՝</strong> նամակագրություն, լուսանկարներ, վճարման կտրոն</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-return-policy-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/return-policy/return-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ReturnPolicyNovoHyComponent {}
|
||||
export class ReturnPolicyNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<p>Для разрешения споров через администрацию платформы:</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>E-mail:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Телефон:</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Телефон:</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Тема:</strong> «Разногласия с продавцом — №Заказа»</li>
|
||||
<li><strong>Приложите:</strong> переписку, снимки, чек оплаты</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-return-policy-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/return-policy/return-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ReturnPolicyNovoRuComponent {}
|
||||
export class ReturnPolicyNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,16 @@ import { TranslateService } from '../../i18n/translate.service';
|
||||
`,
|
||||
styles: [`
|
||||
.dexar-back-btn {
|
||||
position: fixed;
|
||||
top: 76px;
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
left: 20px;
|
||||
z-index: 100;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
padding: 8px 4px;
|
||||
margin-bottom: -40px;
|
||||
width: fit-content;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
svg path {
|
||||
@@ -47,7 +49,7 @@ import { TranslateService } from '../../i18n/translate.service';
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dexar-back-btn {
|
||||
top: 68px;
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
|
||||
svg {
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
<app-region-selector />
|
||||
<app-language-selector />
|
||||
|
||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()" [attr.aria-label]="'header.cart' | translate">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<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>
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
</a>
|
||||
|
||||
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen">
|
||||
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
@@ -118,7 +118,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen">
|
||||
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
|
||||
@@ -18,14 +18,21 @@
|
||||
<div class="item-card">
|
||||
<a [routerLink]="['/item', product.itemID] | langRoute" class="item-link">
|
||||
<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) {
|
||||
<span class="discount-badge">-{{ product.discount }}%</span>
|
||||
}
|
||||
@if (product.badges && product.badges.length > 0) {
|
||||
<div class="item-badges-overlay">
|
||||
@for (badge of product.badges; track badge) {
|
||||
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="item-details">
|
||||
<h3 class="item-name">{{ product.name }}</h3>
|
||||
<h3 class="item-name">{{ itemName(product) }}</h3>
|
||||
|
||||
@if (product.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 { RouterLink } from '@angular/router';
|
||||
import { CarouselModule } from 'primeng/carousel';
|
||||
@@ -7,7 +7,8 @@ import { TagModule } from 'primeng/tag';
|
||||
import { ApiService, CartService } from '../../services';
|
||||
import { Item } from '../../models';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { getDiscountedPrice, getMainImage } from '../../utils/item.utils';
|
||||
import { getDiscountedPrice, getMainImage, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
|
||||
@@ -98,6 +99,10 @@ export class ItemsCarouselComponent implements OnInit {
|
||||
|
||||
readonly getItemImage = getMainImage;
|
||||
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 {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="language-selector">
|
||||
<button class="language-button" (click)="toggleDropdown()">
|
||||
<div class="language-selector" role="listbox">
|
||||
<button class="language-button" (click)="toggleDropdown()" (keydown)="onKeyDown($event)" aria-haspopup="listbox" [attr.aria-expanded]="dropdownOpen">
|
||||
<img [src]="languageService.getCurrentLanguage()?.flagSvg"
|
||||
[alt]="languageService.getCurrentLanguage()?.name"
|
||||
class="language-flag">
|
||||
@@ -13,6 +13,8 @@
|
||||
@for (lang of languageService.languages; track lang.code) {
|
||||
<button
|
||||
class="language-option"
|
||||
role="option"
|
||||
[attr.aria-selected]="languageService.currentLanguage() === lang.code"
|
||||
[class.active]="languageService.currentLanguage() === lang.code"
|
||||
[class.disabled]="!lang.enabled"
|
||||
[disabled]="!lang.enabled"
|
||||
@@ -22,4 +24,25 @@
|
||||
</button>
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -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 { LanguageService, Language } from '../../services/language.service';
|
||||
import { LanguageService, Language, Currency } from '../../services/language.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-language-selector',
|
||||
@@ -10,6 +10,7 @@ import { LanguageService, Language } from '../../services/language.service';
|
||||
})
|
||||
export class LanguageSelectorComponent {
|
||||
dropdownOpen = false;
|
||||
currencyOpen = false;
|
||||
|
||||
constructor(
|
||||
public languageService: LanguageService,
|
||||
@@ -18,6 +19,12 @@ export class LanguageSelectorComponent {
|
||||
|
||||
toggleDropdown(): void {
|
||||
this.dropdownOpen = !this.dropdownOpen;
|
||||
this.currencyOpen = false;
|
||||
}
|
||||
|
||||
toggleCurrency(): void {
|
||||
this.currencyOpen = !this.currencyOpen;
|
||||
this.dropdownOpen = false;
|
||||
}
|
||||
|
||||
selectLanguage(lang: Language): void {
|
||||
@@ -27,14 +34,30 @@ export class LanguageSelectorComponent {
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrency(currency: Currency): void {
|
||||
this.languageService.setCurrency(currency.code);
|
||||
this.currencyOpen = false;
|
||||
}
|
||||
|
||||
closeDropdown(): void {
|
||||
this.dropdownOpen = false;
|
||||
this.currencyOpen = false;
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
this.dropdownOpen = false;
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.toggleDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onClickOutside(event: Event): void {
|
||||
if (!this.elementRef.nativeElement.contains(event.target)) {
|
||||
this.dropdownOpen = false;
|
||||
this.currencyOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,41 @@
|
||||
|
||||
<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>
|
||||
|
||||
@switch (qrStatus()) {
|
||||
@case ('loading') {
|
||||
<div class="qr-container qr-loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
}
|
||||
@case ('ready') {
|
||||
<div class="qr-container">
|
||||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
|
||||
alt="QR Code"
|
||||
width="180"
|
||||
height="180"
|
||||
loading="eager" />
|
||||
</div>
|
||||
}
|
||||
@case ('expired') {
|
||||
<div class="qr-container qr-expired" (click)="refreshQr()">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 4v6h6M23 20v-6h-6"/>
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
|
||||
</svg>
|
||||
<span>{{ 'auth.qrExpired' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<div class="qr-container">
|
||||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
|
||||
alt="QR Code"
|
||||
width="180"
|
||||
height="180"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
|
||||
|
||||
@@ -122,6 +122,42 @@ h2 {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.qr-loading {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 204px;
|
||||
height: 204px;
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e0e0e0;
|
||||
border-top-color: var(--accent-color, #497671);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.qr-expired {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 204px;
|
||||
height: 204px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #999);
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-color, #497671);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnDestroy } from '@angular/core';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CartService } from '../../services/cart.service';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { getDiscountedPrice } from '../../utils/item.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-telegram-login',
|
||||
@@ -9,17 +11,28 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
styleUrls: ['./telegram-login.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TelegramLoginComponent implements OnInit, OnDestroy {
|
||||
export class TelegramLoginComponent implements OnDestroy {
|
||||
private authService = inject(AuthService);
|
||||
private cartService = inject(CartService);
|
||||
|
||||
showDialog = this.authService.showLoginDialog;
|
||||
status = this.authService.status;
|
||||
|
||||
loginUrl = signal('');
|
||||
qrToken = signal('');
|
||||
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
|
||||
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
|
||||
|
||||
private pollTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loginUrl.set(this.authService.getTelegramLoginUrl());
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.showDialog()) {
|
||||
this.initQrLogin();
|
||||
} else {
|
||||
this.stopPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -31,32 +44,111 @@ export class TelegramLoginComponent implements OnInit, OnDestroy {
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
/** Open Telegram login link and start polling for session */
|
||||
openTelegramLogin(): void {
|
||||
window.open(this.loginUrl(), '_blank');
|
||||
this.startPolling();
|
||||
if (!this.pollTimer) {
|
||||
if (this.qrToken()) {
|
||||
this.startPolling(this.qrToken());
|
||||
} else {
|
||||
this.startSessionPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start polling the backend to detect when user completes Telegram auth */
|
||||
private startPolling(): void {
|
||||
refreshQr(): void {
|
||||
this.stopPolling();
|
||||
this.initQrLogin();
|
||||
}
|
||||
|
||||
private initQrLogin(): void {
|
||||
this.qrStatus.set('loading');
|
||||
this.authService.createQrToken().subscribe({
|
||||
next: (res) => {
|
||||
this.loginUrl.set(res.url);
|
||||
this.qrToken.set(res.token);
|
||||
this.qrStatus.set('ready');
|
||||
this.startPolling(res.token);
|
||||
},
|
||||
error: () => {
|
||||
this.loginUrl.set(this.authService.getTelegramLoginUrl());
|
||||
this.qrStatus.set('error');
|
||||
this.startSessionPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startSessionPolling(): 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
|
||||
if (checks > 100) {
|
||||
this.stopPolling();
|
||||
this.qrStatus.set('expired');
|
||||
return;
|
||||
}
|
||||
this.authService.checkSession();
|
||||
// If authenticated, stop polling and close dialog
|
||||
if (this.authService.isAuthenticated()) {
|
||||
this.stopPolling();
|
||||
this.authService.hideLogin();
|
||||
}
|
||||
this.authService.checkSessionOnce().subscribe(session => {
|
||||
if (session && session.active) {
|
||||
this.stopPolling();
|
||||
this.syncCartAndComplete(session.sessionId);
|
||||
}
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
private startPolling(token: string): void {
|
||||
this.stopPolling();
|
||||
if (!token) return;
|
||||
|
||||
let checks = 0;
|
||||
this.pollTimer = setInterval(() => {
|
||||
checks++;
|
||||
if (checks > 100) {
|
||||
this.stopPolling();
|
||||
this.qrStatus.set('expired');
|
||||
return;
|
||||
}
|
||||
|
||||
this.authService.pollQrToken(token).subscribe({
|
||||
next: (res) => {
|
||||
switch (res.status) {
|
||||
case 'confirmed':
|
||||
this.stopPolling();
|
||||
if (res.session) {
|
||||
this.syncCartAndComplete(res.session.sessionId);
|
||||
} else {
|
||||
this.authService.onTelegramLoginComplete();
|
||||
}
|
||||
break;
|
||||
case 'expired':
|
||||
this.stopPolling();
|
||||
this.qrStatus.set('expired');
|
||||
break;
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Network error — keep polling
|
||||
}
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
private syncCartAndComplete(sessionId: string): void {
|
||||
const cartItems = this.cartService.items().map(item => ({
|
||||
itemID: item.itemID,
|
||||
quantity: item.quantity,
|
||||
colour: item.colour || '',
|
||||
size: item.size || '',
|
||||
price: item.discount > 0
|
||||
? item.price * (1 - item.discount / 100)
|
||||
: item.price,
|
||||
}));
|
||||
|
||||
this.authService.syncCart(sessionId, cartItems).subscribe(() => {
|
||||
this.authService.onTelegramLoginComplete();
|
||||
});
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
|
||||
19
src/app/config/constants.ts
Normal file
19
src/app/config/constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Payment polling
|
||||
export const PAYMENT_POLL_INTERVAL_MS = 5000;
|
||||
export const PAYMENT_MAX_CHECKS = 36;
|
||||
export const PAYMENT_TIMEOUT_CLOSE_MS = 3000;
|
||||
export const PAYMENT_ERROR_CLOSE_MS = 4000;
|
||||
export const LINK_COPIED_DURATION_MS = 2000;
|
||||
|
||||
// Infinite scroll
|
||||
export const SCROLL_THRESHOLD_PX = 1200;
|
||||
export const SCROLL_DEBOUNCE_MS = 100;
|
||||
export const ITEMS_PER_PAGE = 50;
|
||||
|
||||
// Search
|
||||
export const SEARCH_DEBOUNCE_MS = 300;
|
||||
export const SEARCH_MIN_LENGTH = 3;
|
||||
|
||||
// Cache
|
||||
export const CACHE_DURATION_MS = 5 * 60 * 1000;
|
||||
export const CATEGORY_CACHE_DURATION_MS = 2 * 60 * 1000;
|
||||
@@ -102,6 +102,10 @@ export const en: Translations = {
|
||||
emailNeedsAt: 'Email must contain @',
|
||||
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
|
||||
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: {
|
||||
title: 'Product search',
|
||||
@@ -134,6 +138,7 @@ export const en: Translations = {
|
||||
emptyTitle: 'Oops! No subcategories yet',
|
||||
emptyDesc: 'There are no subcategories in this section yet, but they will appear soon',
|
||||
goHome: 'Go home',
|
||||
itemsInCategory: 'Items in this category',
|
||||
},
|
||||
itemDetail: {
|
||||
loading: 'Loading...',
|
||||
@@ -148,6 +153,7 @@ export const en: Translations = {
|
||||
mediumStock: 'Running low',
|
||||
addToCart: 'Add to cart',
|
||||
description: 'Description',
|
||||
specifications: 'Specifications',
|
||||
reviews: 'Reviews',
|
||||
yourReview: 'Your review',
|
||||
leaveReview: 'Leave a review',
|
||||
@@ -169,6 +175,8 @@ export const en: Translations = {
|
||||
yesterday: 'Yesterday',
|
||||
daysAgo: 'd. ago',
|
||||
weeksAgo: 'w. ago',
|
||||
colour: 'Colour',
|
||||
size: 'Size',
|
||||
},
|
||||
app: {
|
||||
connecting: 'Connecting to server...',
|
||||
@@ -197,5 +205,6 @@ export const en: Translations = {
|
||||
loginWithTelegram: 'Log in with Telegram',
|
||||
orScanQr: 'Or scan the QR code',
|
||||
loginNote: 'You will be redirected back after login',
|
||||
qrExpired: 'QR code expired. Click to refresh',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Translations } from './translations';
|
||||
import { Translations } from './translations';
|
||||
|
||||
export const hy: Translations = {
|
||||
header: {
|
||||
@@ -6,7 +6,7 @@ export const hy: Translations = {
|
||||
search: 'Որոնում',
|
||||
about: 'Մեր մասին',
|
||||
contacts: 'Կապ',
|
||||
searchPlaceholder: 'Որոնել...',
|
||||
searchPlaceholder: 'Փնտրել...',
|
||||
catalog: 'Կատալոգ',
|
||||
},
|
||||
footer: {
|
||||
@@ -14,7 +14,7 @@ export const hy: Translations = {
|
||||
company: 'Ընկերություն',
|
||||
aboutUs: 'Մեր մասին',
|
||||
contacts: 'Կապ',
|
||||
requisites: 'Վավերապայմաններ',
|
||||
requisites: 'Վճարային տվյալներ',
|
||||
support: 'Աջակցություն',
|
||||
faq: 'ՀՏՀ',
|
||||
delivery: 'Առաքում',
|
||||
@@ -35,133 +35,139 @@ export const hy: Translations = {
|
||||
},
|
||||
home: {
|
||||
welcomeTo: 'Բարի գալուստ {{brand}}',
|
||||
subtitle: 'Գտեք ամեն ինչ մեկ վայրում',
|
||||
subtitle: 'Գտեք այն ամենը, ինչ պետք է՝ մեկ վայրում',
|
||||
startSearch: 'Սկսել որոնումը',
|
||||
loading: 'Կատեգորիաները բեռնվում են...',
|
||||
errorTitle: 'Ինչ-որ բան սխալ է գնացել',
|
||||
loading: 'Բեռնում ենք կատեգորիաները...',
|
||||
errorTitle: 'Ինչ-որ բան սխալ գնաց',
|
||||
retry: 'Փորձել կրկին',
|
||||
categoriesTitle: 'Ապրանքների կատեգորիաներ',
|
||||
categoriesSubtitle: 'Ընտրեք հետաքրքրող կատեգորիան',
|
||||
categoriesSubtitle: 'Ընտրեք ձեզ հետաքրքիր կատեգորիան',
|
||||
categoriesEmpty: 'Կատեգորիաները շուտով կհայտնվեն',
|
||||
categoriesEmptyDesc: 'Մենք աշխատում ենք կատալոգի համալրման վրա',
|
||||
dexarHeroTitle: 'Այստեղ դու կգտնես ամեն ինչ',
|
||||
categoriesEmptyDesc: 'Մենք աշխատում ենք կատալոգի լրացման վրա',
|
||||
dexarHeroTitle: 'Այստեղ կգտնես ամեն ինչ',
|
||||
dexarHeroSubtitle: 'Հազարավոր ապրանքներ մեկ վայրում',
|
||||
dexarHeroTagline: 'պարզ և հարմար',
|
||||
goToCatalog: 'Անցնել կատալոգ',
|
||||
goToCatalog: 'Գնալ կատալոգ',
|
||||
findProduct: 'Գտնել ապրանք',
|
||||
loadingDexar: 'Կատեգորիաները բեռնվում են...',
|
||||
loadingDexar: 'Կատեգորիաների բեռնում...',
|
||||
catalogTitle: 'Ապրանքների կատալոգ',
|
||||
emptyCategoriesDexar: 'Կատեգորիաները դեռ չկան',
|
||||
categoriesSoonDexar: 'Շուտով այստեղ կհայտնվեն ապրանքների կատեգորիաներ',
|
||||
categoriesSoonDexar: 'Շուտով այստեղ կհայտնվեն կատեգորիաներ',
|
||||
itemsCount: '{{count}} ապրանք',
|
||||
},
|
||||
cart: {
|
||||
title: 'Զամբյուղ',
|
||||
clear: 'Մաքրել',
|
||||
empty: 'Զամբյուղը դատարկ է',
|
||||
emptyDesc: 'Ավելացրեք ապրանքներ գնումները սկսելու համար',
|
||||
goShopping: 'Անցնել գնումների',
|
||||
emptyDesc: 'Ավելացրեք ապրանքներ՝ գնումները սկսելու համար',
|
||||
goShopping: 'Գնալ գնումների',
|
||||
total: 'Ընդամենը',
|
||||
items: 'Ապրանքներ',
|
||||
deliveryLabel: 'Առաքում',
|
||||
toPay: 'Վճարման ենթակա',
|
||||
agreeWith: 'Ես համաձայն եմ',
|
||||
publicOffer: 'հանրային օֆերտային',
|
||||
returnPolicy: 'վերադարձի քաղաքականությանը',
|
||||
guaranteeTerms: 'երաշխիքային պայմաններին',
|
||||
privacyPolicy: 'գաղտնիության քաղաքականությանը',
|
||||
publicOffer: 'հանրային օֆերտայի',
|
||||
returnPolicy: 'վերադարձի քաղաքականության',
|
||||
guaranteeTerms: 'երաշխիքային պայմանների',
|
||||
privacyPolicy: 'գաղտնիության քաղաքականության',
|
||||
and: 'և',
|
||||
checkout: 'Ձևակերպել պատվեր',
|
||||
checkout: 'Ձևակերպել պատվերը',
|
||||
close: 'Փակել',
|
||||
creatingPayment: 'Վճարումը ստեղծվում է...',
|
||||
waitFewSeconds: 'Սպասեք մի քանի վայրկյան',
|
||||
scanQr: 'Սկանավորեք QR կոդը վճարման համար',
|
||||
amountToPay: 'Վճարման գումարը՝',
|
||||
creatingPayment: 'Վճարման ստեղծում...',
|
||||
waitFewSeconds: 'Խնդրում ենք սպասել մի քանի վայրկյան',
|
||||
scanQr: 'Սքանավորեք QR կոդը վճարման համար',
|
||||
amountToPay: 'Վճարման գումար՝',
|
||||
waitingPayment: 'Սպասում ենք վճարմանը...',
|
||||
copied: '✓ Պատճենված է',
|
||||
copyLink: 'Պատճենել հղումը',
|
||||
openNewTab: 'Բացել նոր ներդիրում',
|
||||
paymentSuccess: 'Շնորհավորում ենք։ Վճարումը հաջողությամբ կատարվել է։',
|
||||
paymentSuccessDesc: 'Մուտքագրեք ձեր կոնտակտային տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում',
|
||||
paymentSuccess: 'Շնորհավորում ենք! Վճարումը հաջող է անցել!',
|
||||
paymentSuccessDesc: 'Մուտքագրեք ձեր տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում',
|
||||
sending: 'Ուղարկվում է...',
|
||||
send: 'Ուղարկել',
|
||||
paymentTimeout: 'Սպասման ժամանակը սպառվել է',
|
||||
paymentTimeoutDesc: 'Մենք չենք ստացել վճարման հաստատում 3 րոպեի ընթացքում։',
|
||||
paymentTimeout: 'Ժամանակը սպառվեց',
|
||||
paymentTimeoutDesc: 'Մենք չստացանք վճարման հաստատում 3 րոպեի ընթացքում։',
|
||||
autoClose: 'Պատուհանը կփակվի ավտոմատ...',
|
||||
confirmClear: 'Համոզվա՞ծ եք, որ ցանկանում եք մաքրել զամբյուղը։',
|
||||
acceptTerms: 'Խնդրում ենք ընդունել օֆերտայի, վերադարձի և երաշխիքի պայմանները պատվերը հաստատելու համար։',
|
||||
confirmClear: 'Վստա՞հ եք, որ ցանկանում եք մաքրել զամբյուղը',
|
||||
acceptTerms: 'Խնդրում ենք ընդունել պայմանները՝ պատվերը հաստատելու համար։',
|
||||
copyError: 'Պատճենման սխալ՝',
|
||||
emailSuccess: 'Email-ը հաջողությամբ ուղարկվել է։ Ստուգեք ձեր փոստը։',
|
||||
emailError: 'Email ուղարկելու ժամանակ տեղի ունեցավ սխալ։ Խնդրում ենք փորձել կրկին։',
|
||||
emailSuccess: 'Email-ը հաջողությամբ ուղարկվեց։ Ստուգեք ձեր փոստը։',
|
||||
emailError: 'Սխալ email ուղարկելիս։ Խնդրում ենք փորձել կրկին։',
|
||||
phoneRequired: 'Հեռախոսահամարը պարտադիր է',
|
||||
phoneMoreDigits: 'Մուտքագրեք ևս {{count}} թիվ',
|
||||
phoneTooMany: 'Չափազանց շատ թվեր',
|
||||
emailRequired: 'Email-ը պարտադիր է',
|
||||
emailTooShort: 'Email-ը չափազանց կարճ է (նվազագույնը 5 նիշ)',
|
||||
emailTooShort: 'Email-ը չափազանց կարճ է (առնվազն 5 նիշ)',
|
||||
emailTooLong: 'Email-ը չափազանց երկար է (առավելագույնը 100 նիշ)',
|
||||
emailNeedsAt: 'Email-ը պետք է պարունակի @ նշանը',
|
||||
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեն (.com, .ru և այլն)',
|
||||
emailInvalid: 'Email-ի ձևաչափը սխալ է',
|
||||
emailNeedsAt: 'Email-ը պետք է պարունակի @',
|
||||
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
|
||||
emailInvalid: 'Սխալ email ձևաչափ',
|
||||
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
|
||||
loginRequiredDesc: 'Պատվեր ձևակերպելու համար մուտք գործեք Telegram-ով',
|
||||
loginWithTelegram: 'Մուտք Telegram-ով',
|
||||
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
||||
},
|
||||
search: {
|
||||
title: 'Ապրանքների որոնում',
|
||||
placeholder: 'Մուտքագրեք ապրանքի անունը...',
|
||||
placeholder: 'Մուտքագրեք ապրանքի անվանումը...',
|
||||
resultsCount: 'Գտնված ապրանքներ՝',
|
||||
searching: 'Որոնում...',
|
||||
retry: 'Փորձել կրկին',
|
||||
noResults: 'Ոչինչ չի գտնվել',
|
||||
noResultsFor: '"{{query}}" հարցման համար ապրանքներ չեն գտնվել',
|
||||
noResultsFor: '"{{query}}" հարցմամբ ապրանքներ չեն գտնվել',
|
||||
noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր',
|
||||
addToCart: 'Ավելացնել զամբյուղ',
|
||||
loadingMore: 'Բեռնվում է...',
|
||||
loadingMore: 'Բեռնում...',
|
||||
allLoaded: 'Բոլոր արդյունքները բեռնված են',
|
||||
emptyState: 'Մուտքագրեք հարցում ապրանքներ որոնելու համար',
|
||||
of: 'ից',
|
||||
emptyState: 'Մուտքագրեք հարցում որոնման համար',
|
||||
of: '-ից',
|
||||
},
|
||||
category: {
|
||||
retry: 'Փորձել կրկին',
|
||||
addToCart: 'Ավելացնել զամբյուղ',
|
||||
loadingMore: 'Բեռնվում է...',
|
||||
loadingMore: 'Բեռնում...',
|
||||
allLoaded: 'Բոլոր ապրանքները բեռնված են',
|
||||
emptyTitle: 'Ուպս։ Այստեղ դեռ դատարկ է',
|
||||
emptyDesc: 'Այս կատեգորիայում դեռ ապրանքներ չկան, բայց շուտով կհայտնվեն',
|
||||
goHome: 'Գլխավոր էջ',
|
||||
loading: 'Ապրանքները բեռնվում են...',
|
||||
emptyTitle: 'Վա՜յ, այստեղ դեռ դատարկ է',
|
||||
emptyDesc: 'Այս կատեգորիայում դեռ ապրանքներ չկան',
|
||||
goHome: 'Գլխավոր',
|
||||
loading: 'Ապրանքների բեռնում...',
|
||||
},
|
||||
subcategories: {
|
||||
loading: 'Ենթակատեգորիաները բեռնվում են...',
|
||||
loading: 'Ենթակատեգորիաների բեռնում...',
|
||||
retry: 'Փորձել կրկին',
|
||||
emptyTitle: 'Ուպս։ Ենթակատեգորիաներ դեռ չկան',
|
||||
emptyDesc: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան, բայց շուտով կհայտնվեն',
|
||||
goHome: 'Գլխավոր էջ',
|
||||
emptyTitle: 'Ենթակատեգորիաներ չկան',
|
||||
emptyDesc: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան',
|
||||
goHome: 'Գլխավոր',
|
||||
itemsInCategory: 'Ապրանքներ այս կատեգորիայում',
|
||||
},
|
||||
itemDetail: {
|
||||
loading: 'Բեռնվում է...',
|
||||
loadingDexar: 'Ապրանքը բեռնվում է...',
|
||||
loading: 'Բեռնում...',
|
||||
loadingDexar: 'Ապրանքի բեռնում...',
|
||||
back: 'Վերադառնալ',
|
||||
backHome: 'Վերադառնալ գլխավոր էջ',
|
||||
noImage: 'Պատկեր չկա',
|
||||
stock: 'Առկայություն՝',
|
||||
inStock: 'Առկա է',
|
||||
lowStock: 'Մնացել է քիչ',
|
||||
lowStock: 'Քիչ է մնացել',
|
||||
lastItems: 'Վերջին հատերը',
|
||||
mediumStock: 'Վերջանում է',
|
||||
mediumStock: 'Ավարտվում է',
|
||||
addToCart: 'Ավելացնել զամբյուղ',
|
||||
description: 'Նկարագրություն',
|
||||
specifications: 'Բնութագրեր',
|
||||
reviews: 'Կարծիքներ',
|
||||
yourReview: 'Ձեր կարծիքը',
|
||||
leaveReview: 'Թողնել կարծիք',
|
||||
rating: 'Գնահատական՝',
|
||||
reviewPlaceholder: 'Կիսվեք ձեր տպավորություններով ապրանքի մասին...',
|
||||
reviewPlaceholderDexar: 'Կիսվեք ձեր տպավորություններով...',
|
||||
reviewPlaceholder: 'Կիսվեք ձեր կարծիքով...',
|
||||
reviewPlaceholderDexar: 'Կիսվեք տպավորություններով...',
|
||||
anonymous: 'Անանուն',
|
||||
submitting: 'Ուղարկվում է...',
|
||||
submit: 'Ուղարկել',
|
||||
reviewSuccess: 'Շնորհակալություն ձեր կարծիքի համար։',
|
||||
reviewError: 'Ուղարկման սխալ։ Փորձեք ավելի ուշ։',
|
||||
reviewSuccess: 'Շնորհակալություն ձեր կարծիքի համար!',
|
||||
reviewError: 'Սխալ ուղարկելիս։ Փորձեք ավելի ուշ։',
|
||||
defaultUser: 'Օգտատեր',
|
||||
defaultUserDexar: 'Անանուն',
|
||||
noReviews: 'Դեռ կարծիքներ չկան։ Դարձեք առաջինը։',
|
||||
noReviews: 'Կարծիքներ դեռ չկան',
|
||||
qna: 'Հարցեր և պատասխաններ',
|
||||
photo: 'Լուսանկար',
|
||||
reviewsCount: 'կարծիք',
|
||||
@@ -169,34 +175,36 @@ export const hy: Translations = {
|
||||
yesterday: 'Երեկ',
|
||||
daysAgo: 'օր առաջ',
|
||||
weeksAgo: 'շաբաթ առաջ',
|
||||
colour: 'Գույն',
|
||||
size: 'Չափ',
|
||||
},
|
||||
app: {
|
||||
connecting: 'Միացում սերվերին...',
|
||||
serverUnavailable: 'Սերվերը հասանելի չէ',
|
||||
serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետ կապը։',
|
||||
retryConnection: 'Կրկնել փորձը',
|
||||
connecting: 'Կապ սերվերի հետ...',
|
||||
serverUnavailable: 'Սերվերը անհասանելի է',
|
||||
serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետը։',
|
||||
retryConnection: 'Փորձել կրկին',
|
||||
pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս',
|
||||
},
|
||||
carousel: {
|
||||
loading: 'Ապրանքները բեռնվում են...',
|
||||
loading: 'Ապրանքների բեռնում...',
|
||||
addToCart: 'Ավելացնել զամբյուղ',
|
||||
},
|
||||
common: {
|
||||
retry: 'Փորձել կրկին',
|
||||
loading: 'Բեռնվում է...',
|
||||
loading: 'Բեռնում...',
|
||||
},
|
||||
|
||||
location: {
|
||||
allRegions: 'Բոլոր տարածաշրջաններ',
|
||||
chooseRegion: 'Ընտրեք տարածաշրջան',
|
||||
detectAuto: 'Որոշել ինքնաշխատ',
|
||||
allRegions: 'Բոլոր տարածաշրջանները',
|
||||
chooseRegion: 'Ընտրեք տարածաշրջանը',
|
||||
detectAuto: 'Որոշել ավտոմատ',
|
||||
},
|
||||
auth: {
|
||||
loginRequired: 'Մուտք պահանջվում է',
|
||||
loginDescription: 'Պատվերի կատարման համար մուտք արեք Telegram-ի միջոցով',
|
||||
checking: 'Ստուգում է...',
|
||||
loginWithTelegram: 'Մուտք գործել Telegram-ով',
|
||||
orScanQr: 'Կամ սկանավորեք QR կոդը',
|
||||
loginNote: 'Մուտքից հետո դուք կվերադառնավեք',
|
||||
loginRequired: 'Պահանջվում է մուտք',
|
||||
loginDescription: 'Պատվերի համար մուտք գործեք Telegram-ով',
|
||||
checking: 'Ստուգում...',
|
||||
loginWithTelegram: 'Մուտք Telegram-ով',
|
||||
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
||||
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
|
||||
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -102,6 +102,10 @@ export const ru: Translations = {
|
||||
emailNeedsAt: 'Email должен содержать @',
|
||||
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
||||
emailInvalid: 'Некорректный формат email',
|
||||
loginRequired: 'Войдите для оформления',
|
||||
loginRequiredDesc: 'Для оформления заказа войдите через Telegram',
|
||||
loginWithTelegram: 'Войти через Telegram',
|
||||
orScanQr: 'Или отсканируйте QR-код',
|
||||
},
|
||||
search: {
|
||||
title: 'Поиск товаров',
|
||||
@@ -134,6 +138,7 @@ export const ru: Translations = {
|
||||
emptyTitle: 'Упс! Подкатегорий пока нет',
|
||||
emptyDesc: 'В этом разделе ещё нет подкатегорий, но скоро они появятся',
|
||||
goHome: 'На главную',
|
||||
itemsInCategory: 'Товары в этой категории',
|
||||
},
|
||||
itemDetail: {
|
||||
loading: 'Загрузка...',
|
||||
@@ -148,6 +153,7 @@ export const ru: Translations = {
|
||||
mediumStock: 'Заканчивается',
|
||||
addToCart: 'Добавить в корзину',
|
||||
description: 'Описание',
|
||||
specifications: 'Характеристики',
|
||||
reviews: 'Отзывы',
|
||||
yourReview: 'Ваш отзыв',
|
||||
leaveReview: 'Оставить отзыв',
|
||||
@@ -169,6 +175,8 @@ export const ru: Translations = {
|
||||
yesterday: 'Вчера',
|
||||
daysAgo: 'дн. назад',
|
||||
weeksAgo: 'нед. назад',
|
||||
colour: 'Цвет',
|
||||
size: 'Размер',
|
||||
},
|
||||
app: {
|
||||
connecting: 'Подключение к серверу...',
|
||||
@@ -197,5 +205,6 @@ export const ru: Translations = {
|
||||
loginWithTelegram: 'Войти через Telegram',
|
||||
orScanQr: 'Или отсканируйте QR-код',
|
||||
loginNote: 'После входа вы будете перенаправлены обратно',
|
||||
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -100,6 +100,10 @@ export interface Translations {
|
||||
emailNeedsAt: string;
|
||||
emailNeedsDomain: string;
|
||||
emailInvalid: string;
|
||||
loginRequired: string;
|
||||
loginRequiredDesc: string;
|
||||
loginWithTelegram: string;
|
||||
orScanQr: string;
|
||||
};
|
||||
search: {
|
||||
title: string;
|
||||
@@ -132,6 +136,7 @@ export interface Translations {
|
||||
emptyTitle: string;
|
||||
emptyDesc: string;
|
||||
goHome: string;
|
||||
itemsInCategory: string;
|
||||
};
|
||||
itemDetail: {
|
||||
loading: string;
|
||||
@@ -146,6 +151,7 @@ export interface Translations {
|
||||
mediumStock: string;
|
||||
addToCart: string;
|
||||
description: string;
|
||||
specifications: string;
|
||||
reviews: string;
|
||||
yourReview: string;
|
||||
leaveReview: string;
|
||||
@@ -167,6 +173,8 @@ export interface Translations {
|
||||
yesterday: string;
|
||||
daysAgo: string;
|
||||
weeksAgo: string;
|
||||
colour: string;
|
||||
size: string;
|
||||
};
|
||||
app: {
|
||||
connecting: string;
|
||||
@@ -195,5 +203,6 @@ export interface Translations {
|
||||
loginWithTelegram: string;
|
||||
orScanQr: string;
|
||||
loginNote: string;
|
||||
qrExpired: string;
|
||||
};
|
||||
}
|
||||
|
||||
67
src/app/interceptors/api-headers.interceptor.ts
Normal file
67
src/app/interceptors/api-headers.interceptor.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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',
|
||||
};
|
||||
|
||||
const SESSION_STORAGE_KEY = 'web_session_id';
|
||||
|
||||
/** Generate a 32-char hex string (GUID without dashes) */
|
||||
function generateSessionId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Get or create a persistent anonymous session ID */
|
||||
function getAnonymousSessionId(): string {
|
||||
let id = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!id || id.length !== 32) {
|
||||
id = generateSessionId();
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
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');
|
||||
headers = headers.set('WebSessionID', session?.sessionId || getAnonymousSessionId());
|
||||
|
||||
return next(req.clone({ headers }));
|
||||
};
|
||||
@@ -2,8 +2,9 @@ import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
||||
import { of } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
import { CACHE_DURATION_MS, CATEGORY_CACHE_DURATION_MS } from '../config/constants';
|
||||
|
||||
const cache = new Map<string, { response: HttpResponse<unknown>, timestamp: number }>();
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 минут
|
||||
|
||||
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
// Кэшируем только GET запросы
|
||||
@@ -11,12 +12,16 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
// Кэшируем только запросы списка категорий (не товары категорий)
|
||||
const shouldCache = req.url.match(/\/category$/) !== null;
|
||||
if (!shouldCache) {
|
||||
// Кэшируем списки категорий, товары категорий и отдельные товары
|
||||
const isCategoryList = /\/category$/.test(req.url);
|
||||
const isCategoryItems = /\/category\/[^/?]+/.test(req.url);
|
||||
const isItem = /\/items\/[^/?]+/.test(req.url);
|
||||
if (!isCategoryList && !isCategoryItems && !isItem) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const ttl = isCategoryList ? CACHE_DURATION_MS : CATEGORY_CACHE_DURATION_MS;
|
||||
|
||||
// Cleanup expired entries before checking
|
||||
cleanupExpiredCache();
|
||||
|
||||
@@ -25,7 +30,7 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
// Проверяем наличие и актуальность кэша
|
||||
if (cachedResponse) {
|
||||
const age = Date.now() - cachedResponse.timestamp;
|
||||
if (age < CACHE_DURATION) {
|
||||
if (age < ttl) {
|
||||
return of(cachedResponse.response.clone());
|
||||
} else {
|
||||
cache.delete(req.url);
|
||||
@@ -53,7 +58,7 @@ export function clearCache(): void {
|
||||
function cleanupExpiredCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [url, data] of cache.entries()) {
|
||||
if (now - data.timestamp >= CACHE_DURATION) {
|
||||
if (now - data.timestamp >= CACHE_DURATION_MS) {
|
||||
cache.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
832
src/app/interceptors/mock-data.interceptor.ts
Normal file
832
src/app/interceptors/mock-data.interceptor.ts
Normal file
@@ -0,0 +1,832 @@
|
||||
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 | string): any[] {
|
||||
return getAllVisibleItems().filter(i =>
|
||||
i.categoryID === categoryID || i.subcategoryId === categoryID
|
||||
);
|
||||
}
|
||||
|
||||
function respond<T>(body: T, delayMs = 150) {
|
||||
return of(new HttpResponse({ status: 200, body })).pipe(delay(delayMs));
|
||||
}
|
||||
|
||||
// ─── Mock Auth State ───
|
||||
let mockQrPollCount = 0;
|
||||
|
||||
// ─── 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 /auth/session
|
||||
if (url.includes('/auth/session') && req.method === 'GET') {
|
||||
return respond({ active: false }, 100);
|
||||
}
|
||||
|
||||
// ── POST /auth/qr/create
|
||||
if (url.includes('/auth/qr/create') && req.method === 'POST') {
|
||||
const token = 'mock-qr-token-' + Date.now();
|
||||
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
||||
mockQrPollCount = 0;
|
||||
return respond({
|
||||
token,
|
||||
url: `https://t.me/${botUsername}?start=qr_${token}`
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// ── GET /auth/qr/poll
|
||||
if (url.includes('/auth/qr/poll') && req.method === 'GET') {
|
||||
mockQrPollCount++;
|
||||
// Simulate confirmed after 3 polls (~9 seconds)
|
||||
if (mockQrPollCount >= 3) {
|
||||
return respond({
|
||||
status: 'confirmed',
|
||||
session: {
|
||||
sessionId: 'mock-session-' + Date.now(),
|
||||
active: true,
|
||||
displayName: 'Telegram User',
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString()
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
return respond({ status: 'pending' }, 200);
|
||||
}
|
||||
|
||||
// ── 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\/([^/?]+)$/);
|
||||
if (catItemsMatch && req.method === 'GET') {
|
||||
const raw = catItemsMatch[1];
|
||||
const num = Number(raw);
|
||||
const catId = Number.isFinite(num) ? num : raw;
|
||||
const items = getItemsByCategoryId(catId);
|
||||
return respond(items);
|
||||
}
|
||||
|
||||
// ── GET /item/:id
|
||||
const itemMatch = url.match(/\/item\/([^/?]+)$/);
|
||||
if (itemMatch && req.method === 'GET') {
|
||||
const raw = itemMatch[1];
|
||||
const num = Number(raw);
|
||||
const item = MOCK_ITEMS.find(i =>
|
||||
Number.isFinite(num) ? i.itemID === num : i.id === raw
|
||||
);
|
||||
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 /websession/:id (add to cart)
|
||||
if (url.match(/\/websession\/[^/]+$/) && req.method === 'POST') {
|
||||
return respond({
|
||||
sessionId: 'mock-session',
|
||||
Status: true,
|
||||
cart: req.body
|
||||
});
|
||||
}
|
||||
|
||||
// ── POST /websession/:id/qr (create payment QR)
|
||||
if (url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'POST') {
|
||||
return respond({
|
||||
qrId: 'mock-qr-' + Date.now(),
|
||||
qrStatus: 'NEW',
|
||||
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);
|
||||
}
|
||||
|
||||
// ── POST /items/:id/callback (review)
|
||||
if (url.match(/\/items\/\d+\/callback$/) && 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 /websession/:id/:qrId (check QR payment status)
|
||||
if (url.match(/\/websession\/[^/]+\/[^/]+$/) && !url.match(/\/websession\/[^/]+\/qr$/) && 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()
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Fallback — pass through
|
||||
return next(req);
|
||||
};
|
||||
@@ -17,4 +17,9 @@ export interface TelegramAuthData {
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface QrPollResponse {
|
||||
status: 'pending' | 'confirmed' | 'expired';
|
||||
session?: AuthSession;
|
||||
}
|
||||
|
||||
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ItemName } from './item.model';
|
||||
|
||||
export interface Category {
|
||||
categoryID: number;
|
||||
name: string;
|
||||
@@ -5,5 +7,32 @@ export interface Category {
|
||||
icon?: string;
|
||||
wideBanner?: string;
|
||||
itemCount?: number;
|
||||
categoriesCount?: number;
|
||||
priority?: number;
|
||||
names?: ItemName[];
|
||||
translations?: Record<string, CategoryTranslation>;
|
||||
|
||||
// BackOffice API fields
|
||||
id?: string;
|
||||
visible?: boolean;
|
||||
img?: string;
|
||||
projectId?: string;
|
||||
subcategories?: Subcategory[];
|
||||
}
|
||||
|
||||
export interface Subcategory {
|
||||
id: string;
|
||||
name: string;
|
||||
visible?: boolean;
|
||||
priority?: number;
|
||||
img?: string;
|
||||
categoryId: string;
|
||||
parentId: string;
|
||||
itemCount?: number;
|
||||
hasItems?: boolean;
|
||||
subcategories?: Subcategory[];
|
||||
}
|
||||
|
||||
export interface CategoryTranslation {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,25 @@ export interface Photo {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface DescriptionField {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id?: string;
|
||||
text: string;
|
||||
author?: string;
|
||||
stars?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ItemTranslation {
|
||||
name?: string;
|
||||
simpleDescription?: string;
|
||||
description?: DescriptionField[];
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
rating?: number;
|
||||
content?: string;
|
||||
@@ -21,6 +40,36 @@ export interface Question {
|
||||
answer: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
like?: number;
|
||||
dislike?: 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;
|
||||
}
|
||||
|
||||
/** Item variant detail (price, size, colour per variant) */
|
||||
export interface ItemDetail {
|
||||
color?: string;
|
||||
colour?: string;
|
||||
size?: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
@@ -36,7 +85,30 @@ export interface Item {
|
||||
rating: number;
|
||||
callbacks: Review[] | 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[];
|
||||
visits?: number;
|
||||
itemDetails?: ItemDetail[];
|
||||
}
|
||||
|
||||
export interface CartItem extends Item {
|
||||
|
||||
@@ -31,12 +31,12 @@
|
||||
(touchstart)="onSwipeStart(item.itemID, $event)">
|
||||
<div class="cart-item">
|
||||
<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>
|
||||
|
||||
<div class="item-info">
|
||||
<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">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
@@ -44,17 +44,39 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="item-description">{{ item.description.substring(0, 100) }}...</p>
|
||||
<p class="item-description">{{ itemDesc(item) || '' }}...</p>
|
||||
|
||||
@if (item.colour || (item.size && item.size.toLowerCase() !== 'default')) {
|
||||
<div class="cart-item-variants">
|
||||
@if (item.colour) {
|
||||
<span class="cart-variant cart-variant-colour">
|
||||
{{ 'itemDetail.colour' | translate }}:
|
||||
<span class="cart-colour-swatch" [style.background-color]="item.colour" [title]="item.colour"></span>
|
||||
</span>
|
||||
}
|
||||
@if (item.size && item.size.toLowerCase() !== 'default') {
|
||||
<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-pricing">
|
||||
@if (item.discount > 0) {
|
||||
<div class="price-with-discount">
|
||||
<span class="original-price">{{ item.price }} ₽</span>
|
||||
<span class="current-price">{{ getDiscountedPrice(item) | number:'1.2-2' }} ₽</span>
|
||||
<span class="original-price">{{ item.price }} {{ item.currency }}</span>
|
||||
<span class="current-price">{{ getDiscountedPrice(item) | number:'1.2-2' }} {{ item.currency }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="current-price">{{ item.price }} ₽</span>
|
||||
<span class="current-price">{{ item.price }} {{ item.currency }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -91,17 +113,17 @@
|
||||
|
||||
<div class="summary-row">
|
||||
<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 class="summary-row delivery">
|
||||
<span>{{ 'cart.deliveryLabel' | translate }}</span>
|
||||
<span>0 ₽</span>
|
||||
<span>0 {{ currentCurrency }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row total">
|
||||
<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 class="terms-agreement">
|
||||
@@ -130,6 +152,36 @@
|
||||
>
|
||||
{{ 'cart.checkout' | translate }}
|
||||
</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>
|
||||
}
|
||||
@@ -166,7 +218,7 @@
|
||||
<div class="payment-info">
|
||||
<div class="payment-amount">
|
||||
<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 class="waiting-indicator">
|
||||
@@ -256,3 +308,5 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-telegram-login />
|
||||
|
||||
@@ -364,6 +364,35 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cart-item-variants {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
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;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cart-colour-swatch {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.item-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -464,6 +493,35 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cart-item-variants {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
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;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cart-colour-swatch {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.item-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -689,6 +747,85 @@
|
||||
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
|
||||
@@ -775,6 +912,85 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-login-gate {
|
||||
margin-top: 20px;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background: rgba(16, 185, 129, 0.04);
|
||||
border: 1px dashed rgba(16, 185, 129, 0.3);
|
||||
text-align: center;
|
||||
|
||||
.login-gate-icon {
|
||||
margin: 0 auto 12px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-gate-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.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: 12px;
|
||||
background: #2AABEE;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&: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: #9ca3af;
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
display: inline-flex;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Terms agreement - shared base
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user