37 Commits

Author SHA1 Message Date
sdarbinyan
5566e011b7 fixed cart 2026-03-24 03:24:34 +04:00
sdarbinyan
ee23fd2d3c color 2026-03-24 03:12:04 +04:00
sdarbinyan
2a41062769 random 2026-03-24 02:58:51 +04:00
sdarbinyan
6624de7a32 random items 2026-03-24 02:52:39 +04:00
sdarbinyan
44553f5bd4 changes 2026-03-24 02:46:58 +04:00
sdarbinyan
5ed255dddb Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-03-24 02:27:59 +04:00
sdarbinyan
650bf137f2 fixes 2026-03-24 02:25:50 +04:00
root
3a8bc2f893 change ports in start 2026-03-23 21:31:26 +00:00
root
d29de100c6 add loccal changes 2026-03-23 21:20:11 +00:00
sdarbinyan
97214c3a90 Merge branch 'back-office-integration'
# Conflicts:
#	src/app/pages/cart/cart.component.ts
#	src/app/pages/category/category.component.html
#	src/app/pages/category/category.component.ts
#	src/app/pages/item-detail/item-detail.component.html
#	src/app/pages/item-detail/item-detail.component.ts
#	src/app/pages/legal/company-details/en/company-details-en.component.html
#	src/app/pages/legal/company-details/hy/company-details-hy.component.html
#	src/app/pages/legal/company-details/ru/company-details-ru.component.html
#	src/app/pages/legal/public-offer/en/public-offer-en.component.html
#	src/app/pages/legal/public-offer/ru/public-offer-ru.component.html
#	src/app/pages/search/search.component.ts
#	src/app/services/api.service.ts
2026-03-24 00:18:13 +04:00
sdarbinyan
56f4c56b9e integration new apis 2026-03-24 00:09:11 +04:00
sdarbinyan
0b3b2ee463 changes 2026-03-06 18:40:58 +04:00
sdarbinyan
c3e4e695eb changes and optimisations 2026-03-06 17:45:34 +04:00
sdarbinyan
c112aded47 added sceleton for loading 2026-03-06 17:22:35 +04:00
sdarbinyan
75f029b872 added condition 2026-03-06 16:59:01 +04:00
root
f823df7e15 Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-03-05 16:49:39 +00:00
sdarbinyan
af78c053ba fixed design 2026-03-05 20:45:15 +04:00
root
4ef4223367 Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-03-05 16:27:13 +00:00
sdarbinyan
7b18376d28 added info for legal 2026-03-05 20:23:42 +04:00
root
c64b9cfee8 Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-03-04 14:20:07 +00:00
sdarbinyan
712281d2e8 closed en/am 2026-03-04 16:45:01 +04:00
sdarbinyan
0626dcbe46 changes in legal 2026-03-04 16:40:25 +04:00
root
d288a5fb3c Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-03-02 08:57:24 +00:00
sdarbinyan
3445f55758 updates 2026-03-01 02:43:14 +04:00
sdarbinyan
350581cbe9 changes for md 2026-02-28 17:42:36 +04:00
sdarbinyan
377da22761 Merge branch 'auth-system' into back-office-integration 2026-02-28 17:37:14 +04:00
sdarbinyan
421346d957 Merge remote-tracking branch 'origin' into back-office-integration 2026-02-28 16:13:14 +04:00
sdarbinyan
d6097e2b5d style fixes 2026-02-20 10:58:06 +04:00
sdarbinyan
369af40f20 bo integration 2026-02-20 10:44:03 +04:00
root
75b45abe4f Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-02-19 21:32:07 +00:00
root
2bd98b29eb Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-02-18 14:07:44 +00:00
root
82cbf07120 okMerge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-02-14 15:28:51 +00:00
root
e07356a700 add new server 2026-02-14 09:52:29 +00:00
root
5068a3a114 Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-02-14 09:51:37 +00:00
root
333ea45c38 Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-01-22 20:35:13 +00:00
root
b22390f3eb Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-01-22 20:27:30 +00:00
root
3f285ca15f local build 2026-01-22 11:58:50 +00:00
89 changed files with 7291 additions and 2002 deletions

2
.gitignore vendored
View File

@@ -38,7 +38,7 @@ yarn-error.log
/libpeerconnection.log /libpeerconnection.log
testem.log testem.log
/typings /typings
/public/images/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View File

@@ -154,7 +154,8 @@
}, },
"serve": { "serve": {
"options": { "options": {
"allowedHosts": ["novo.market", "dexarmarket.ru", "localhost"] "allowedHosts": ["novo.market", "dexarmarket.ru", "localhost"],
"proxyConfig": "proxy.conf.json"
}, },
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"configurations": { "configurations": {
@@ -175,28 +176,9 @@
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular/build: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"
]
}
} }
} }
} }
} }
} }

View File

@@ -1,24 +1,471 @@
# API Changes Required for Backend # Complete Backend API Documentation
## Overview > **Last updated:** February 2026
> **Frontend:** Angular 21 · Dual-brand (Dexar + Novo)
Frontend has been updated with two new features: > **Covers:** Catalog, Cart, Payments, Reviews, Regions, Auth, i18n, BackOffice
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`
--- ---
## 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 ```json
[ [
{ {
@@ -46,52 +493,44 @@ Returns the list of regions where the marketplace operates.
``` ```
**Region object:** **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 ## 8. Authentication (Telegram Login)
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&region=spb` |
| `GET /item/:id` | `GET /item/123?region=yerevan` |
| `GET /searchitems` | `GET /searchitems?search=phone&region=moscow` |
| `GET /randomitems` | `GET /randomitems?count=5&region=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
Authentication is **Telegram-based** with **cookie sessions** (HttpOnly, Secure, SameSite=None). 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 ```json
{ {
"sessionId": "sess_abc123", "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 ```json
{ {
"sessionId": "sess_abc123", "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 ```json
{ { "error": "No active session" }
"error": "No active session"
}
``` ```
**AuthSession object:** **AuthSession object:**
| Field | Type | Required | Description |
|------------------|---------|----------|------------------------------------------| | Field | Type | Required | Description |
| `sessionId` | string | yes | Unique session ID | |------------------|---------|----------|--------------------------------------------|
| `telegramUserId` | number | yes | Telegram user ID | | `sessionId` | string | yes | Unique session ID |
| `username` | string? | no | Telegram @username (can be null) | | `telegramUserId` | number | yes | Telegram user ID |
| `displayName` | string | yes | User display name (first_name + last_name) | | `username` | string? | no | Telegram @username (can be null) |
| `active` | boolean | yes | Whether session is currently valid | | `displayName` | string | yes | User display name (first + last) |
| `expiresAt` | string | yes | ISO 8601 expiration datetime | | `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:** **Request body (from bot):**
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):
```json ```json
{ {
"id": 123456789, "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 ```json
{ {
"sessionId": "sess_abc123", "sessionId": "sess_abc123",
@@ -167,93 +598,89 @@ This is the URL the Telegram bot redirects to after the user starts the bot.
``` ```
**Cookie requirements:** **Cookie requirements:**
| Attribute | Value | Notes |
|------------|----------------|------------------------------------------| | Attribute | Value | Notes |
| `HttpOnly` | `true` | Not accessible via JS | |------------|----------------|--------------------------------------------|
| `Secure` | `true` | HTTPS only | | `HttpOnly` | `true` | Not accessible via JS |
| `Secure` | `true` | HTTPS only |
| `SameSite` | `None` | Required for cross-origin (API ≠ frontend) | | `SameSite` | `None` | Required for cross-origin (API ≠ frontend) |
| `Path` | `/` | | | `Path` | `/` | |
| `Max-Age` | `86400` (24h) | Or as needed | | `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.
--- ---
### 2.3 `POST /auth/logout` — End session ### `POST /auth/logout` — End session
**Request:** **Request:** Cookies only, empty body `{}`
- Cookies: session cookie
- CORS: `withCredentials: true`
- Body: `{}` (empty)
**Response `200 OK`:** **Response `200`:**
```json ```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-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-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 ## 11. Telegram Bot Setup
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
Each brand needs its own bot: Each brand needs its own bot:
- **Dexar:** `@dexarmarket_bot` - **Dexar:** `@dexarmarket_bot`
@@ -262,5 +689,38 @@ Each brand needs its own bot:
The bot should: The bot should:
1. Listen for `/start auth_{callbackUrl}` command 1. Listen for `/start auth_{callbackUrl}` command
2. Extract the callback URL 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` 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
View 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 сессии (если есть) для привязки к заказу, но не требуют авторизации строго. Фронтенд проверяет авторизацию перед оформлением заказа.

View File

@@ -1,11 +1,748 @@
bro we need to do changes, that client required General Information
1. we need to add location logic 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.
1.1 the catalogs will come or for global or for exact region Header:
1.2 need to add a place where the user can choose his region like city if choosed moscow the country is set russian “Authorization”: {JSON WITH KEY AND PARTNERID}
1.3 can we try to understand what country is user logged or whach city by global ip and set it? “X-Region” : Moscow | Yerevan | ST. Petersburg
2. we need to add somekind of user login logic “X-Language” : RU | AM | EN
2.1 user can add to cart, look the items and etc without logged in, but when he is going to buy/pay -> “WebSessionID” : f02fe5d6-c6ae-4b2e-9b4d-687534e11b01
at first he have to login with telegram, i will send you the bots adress. “Currency” :RUB | AMD | USD
2.1.1 if is not logged -> will see the QR or link for logging via telegram Root:
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 API.dexarmarket.ru
2.2 and when user is logged, that time he can do a payment
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"`

View File

@@ -36,6 +36,9 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" 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 compression (if available)
# brotli on; # brotli on;

View File

@@ -30,7 +30,9 @@
{ {
"name": "api-cache", "name": "api-cache",
"urls": [ "urls": [
"/api/**" "/api/**",
"https://api.dexarmarket.ru:445/**",
"https://api.novo.market:444/**"
], ],
"cacheConfig": { "cacheConfig": {
"maxSize": 100, "maxSize": 100,

73
package-lock.json generated
View File

@@ -8,11 +8,15 @@
"name": "dexarmarket", "name": "dexarmarket",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@angular/animations": "^21.1.5",
"@angular/cdk": "^21.1.5",
"@angular/common": "^21.0.6", "@angular/common": "^21.0.6",
"@angular/compiler": "^21.0.6", "@angular/compiler": "^21.0.6",
"@angular/core": "^21.0.6", "@angular/core": "^21.0.6",
"@angular/forms": "^21.0.6", "@angular/forms": "^21.0.6",
"@angular/material": "^21.1.5",
"@angular/platform-browser": "^21.0.6", "@angular/platform-browser": "^21.0.6",
"@angular/platform-browser-dynamic": "^21.1.5",
"@angular/router": "^21.0.6", "@angular/router": "^21.0.6",
"@angular/service-worker": "^21.0.6", "@angular/service-worker": "^21.0.6",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
@@ -324,6 +328,21 @@
"yarn": ">= 1.13.0" "yarn": ">= 1.13.0"
} }
}, },
"node_modules/@angular/animations": {
"version": "21.1.5",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.1.5.tgz",
"integrity": "sha512-gsqHX8lCYV8cgVtHs0iLwrX8SVlmcjUF44l/xCc/jBC/TeKWRl2e6Jqrn1Wcd0NDlGiNsm+mYNyqMyy5/I7kjw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/core": "21.1.5"
}
},
"node_modules/@angular/build": { "node_modules/@angular/build": {
"version": "21.1.0", "version": "21.1.0",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.0.tgz", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.0.tgz",
@@ -472,6 +491,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@angular/cdk": {
"version": "21.1.5",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.5.tgz",
"integrity": "sha512-AlQPgqe3LLwXCyrDwYSX3m/WKnl2ppCMW7Gb+7bJpIcpMdWYEpSOSQF318jXGYIysKg43YbdJ1tWhJWY/cbn3w==",
"license": "MIT",
"dependencies": {
"parse5": "^8.0.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^21.0.0 || ^22.0.0",
"@angular/core": "^21.0.0 || ^22.0.0",
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/cli": { "node_modules/@angular/cli": {
"version": "21.1.0", "version": "21.1.0",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.0.tgz", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.0.tgz",
@@ -613,6 +648,23 @@
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/material": {
"version": "21.1.5",
"resolved": "https://registry.npmjs.org/@angular/material/-/material-21.1.5.tgz",
"integrity": "sha512-D6JvFulPvIKhPJ52prMV7DxwYMzcUpHar11ZcMb7r9WQzUfCS3FDPXfMAce5n3h+3kFccfmmGpnyBwqTlLPSig==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/cdk": "21.1.5",
"@angular/common": "^21.0.0 || ^22.0.0",
"@angular/core": "^21.0.0 || ^22.0.0",
"@angular/forms": "^21.0.0 || ^22.0.0",
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": { "node_modules/@angular/platform-browser": {
"version": "21.0.6", "version": "21.0.6",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.6.tgz", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.6.tgz",
@@ -635,6 +687,24 @@
} }
} }
}, },
"node_modules/@angular/platform-browser-dynamic": {
"version": "21.1.5",
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.1.5.tgz",
"integrity": "sha512-Pd8nPbJSIONnze1WS9wLBAtaFw4TYIH+ZGjKHS9G1E9l09tDWtHWyB7dY82Sc//Nc8iR4V7dcsbUmFjOJHThww==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/common": "21.1.5",
"@angular/compiler": "21.1.5",
"@angular/core": "21.1.5",
"@angular/platform-browser": "21.1.5"
}
},
"node_modules/@angular/router": { "node_modules/@angular/router": {
"version": "21.0.6", "version": "21.0.6",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.6.tgz", "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.6.tgz",
@@ -7687,7 +7757,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"entities": "^6.0.0" "entities": "^6.0.0"
@@ -7741,7 +7810,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
@@ -9512,3 +9580,4 @@
} }
} }
} }

View File

@@ -16,11 +16,15 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^21.1.5",
"@angular/cdk": "^21.1.5",
"@angular/common": "^21.0.6", "@angular/common": "^21.0.6",
"@angular/compiler": "^21.0.6", "@angular/compiler": "^21.0.6",
"@angular/core": "^21.0.6", "@angular/core": "^21.0.6",
"@angular/forms": "^21.0.6", "@angular/forms": "^21.0.6",
"@angular/material": "^21.1.5",
"@angular/platform-browser": "^21.0.6", "@angular/platform-browser": "^21.0.6",
"@angular/platform-browser-dynamic": "^21.1.5",
"@angular/router": "^21.0.6", "@angular/router": "^21.0.6",
"@angular/service-worker": "^21.0.6", "@angular/service-worker": "^21.0.6",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
@@ -43,3 +47,4 @@
"typescript": "~5.9.3" "typescript": "~5.9.3"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,5 +1,4 @@
{ {
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"name": "Novo Market - Интернет-магазин", "name": "Novo Market - Интернет-магазин",
"short_name": "Novo", "short_name": "Novo",
"description": "Novo Market - ваш онлайн магазин качественных товаров с доставкой", "description": "Novo Market - ваш онлайн магазин качественных товаров с доставкой",
@@ -12,34 +11,10 @@
"categories": ["shopping", "lifestyle"], "categories": ["shopping", "lifestyle"],
"icons": [ "icons": [
{ {
"src": "icons/icon-72x72.png", "src": "assets/images/novo-favicon.svg",
"sizes": "72x72", "sizes": "any",
"type": "image/png", "type": "image/svg+xml",
"purpose": "maskable any" "purpose": "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": "icons/icon-192x192.png", "src": "icons/icon-192x192.png",
@@ -47,12 +22,6 @@
"type": "image/png", "type": "image/png",
"purpose": "maskable any" "purpose": "maskable any"
}, },
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{ {
"src": "icons/icon-512x512.png", "src": "icons/icon-512x512.png",
"sizes": "512x512", "sizes": "512x512",

View File

@@ -11,34 +11,10 @@
"categories": ["shopping", "marketplace"], "categories": ["shopping", "marketplace"],
"icons": [ "icons": [
{ {
"src": "icons/icon-72x72.png", "src": "assets/images/dexar-favicon.svg",
"sizes": "72x72", "sizes": "any",
"type": "image/png", "type": "image/svg+xml",
"purpose": "maskable any" "purpose": "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": "icons/icon-192x192.png", "src": "icons/icon-192x192.png",
@@ -46,12 +22,6 @@
"type": "image/png", "type": "image/png",
"purpose": "maskable any" "purpose": "maskable any"
}, },
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{ {
"src": "icons/icon-512x512.png", "src": "icons/icon-512x512.png",
"sizes": "512x512", "sizes": "512x512",

View File

@@ -4,6 +4,8 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { cacheInterceptor } from './interceptors/cache.interceptor'; import { cacheInterceptor } from './interceptors/cache.interceptor';
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
import { provideServiceWorker } from '@angular/service-worker'; import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
@@ -15,7 +17,7 @@ export const appConfig: ApplicationConfig = {
withInMemoryScrolling({ scrollPositionRestoration: 'top' }) withInMemoryScrolling({ scrollPositionRestoration: 'top' })
), ),
provideHttpClient( provideHttpClient(
withInterceptors([cacheInterceptor]) withInterceptors([mockDataInterceptor, apiHeadersInterceptor, cacheInterceptor])
), ),
provideServiceWorker('ngsw-worker.js', { provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(), enabled: !isDevMode(),

View File

@@ -12,10 +12,10 @@
</div> </div>
} @else { } @else {
<app-header></app-header> <app-header></app-header>
@if (!isHomePage()) {
<app-back-button />
}
<main class="main-content"> <main class="main-content">
@if (!isHomePage()) {
<app-back-button />
}
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<app-footer></app-footer> <app-footer></app-footer>

View File

@@ -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();
});
});

View File

@@ -17,14 +17,16 @@ import { TranslateService } from '../../i18n/translate.service';
`, `,
styles: [` styles: [`
.dexar-back-btn { .dexar-back-btn {
position: fixed; position: sticky;
top: 76px; top: 72px;
left: 20px; left: 20px;
z-index: 100; z-index: 100;
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 4px; padding: 8px 4px;
margin-bottom: -40px;
width: fit-content;
transition: transform 0.2s ease; transition: transform 0.2s ease;
svg path { svg path {
@@ -47,7 +49,7 @@ import { TranslateService } from '../../i18n/translate.service';
@media (max-width: 768px) { @media (max-width: 768px) {
.dexar-back-btn { .dexar-back-btn {
top: 68px; top: 64px;
left: 12px; left: 12px;
svg { svg {

View File

@@ -30,8 +30,8 @@
<app-region-selector /> <app-region-selector />
<app-language-selector /> <app-language-selector />
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()"> <a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()" [attr.aria-label]="'header.cart' | translate">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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="9" cy="21" r="1"></circle>
<circle cx="20" 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> <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> </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> <span></span>
<span></span> <span></span>
@@ -118,7 +118,7 @@
</div> </div>
<!-- Mobile Menu Toggle --> <!-- 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> <span></span>
<span></span> <span></span>

View File

@@ -18,14 +18,21 @@
<div class="item-card"> <div class="item-card">
<a [routerLink]="['/item', product.itemID] | langRoute" class="item-link"> <a [routerLink]="['/item', product.itemID] | langRoute" class="item-link">
<div class="item-image"> <div class="item-image">
<img [src]="getItemImage(product)" [alt]="product.name" loading="lazy" /> <img [src]="getItemImage(product)" [alt]="itemName(product)" loading="lazy" />
@if (product.discount > 0) { @if (product.discount > 0) {
<span class="discount-badge">-{{ product.discount }}%</span> <span class="discount-badge">-{{ product.discount }}%</span>
} }
@if (product.badges && product.badges.length > 0) {
<div class="item-badges-overlay">
@for (badge of product.badges; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
</div> </div>
<div class="item-details"> <div class="item-details">
<h3 class="item-name">{{ product.name }}</h3> <h3 class="item-name">{{ itemName(product) }}</h3>
@if (product.rating) { @if (product.rating) {
<div class="item-rating"> <div class="item-rating">

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, signal, ChangeDetectionStrategy } from '@angular/core'; import { Component, OnInit, signal, ChangeDetectionStrategy, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { CarouselModule } from 'primeng/carousel'; import { CarouselModule } from 'primeng/carousel';
@@ -7,7 +7,8 @@ import { TagModule } from 'primeng/tag';
import { ApiService, CartService } from '../../services'; import { ApiService, CartService } from '../../services';
import { Item } from '../../models'; import { Item } from '../../models';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { getDiscountedPrice, getMainImage } from '../../utils/item.utils'; import { getDiscountedPrice, getMainImage, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
import { LanguageService } from '../../services/language.service';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
@@ -98,6 +99,10 @@ export class ItemsCarouselComponent implements OnInit {
readonly getItemImage = getMainImage; readonly getItemImage = getMainImage;
readonly getDiscountedPrice = getDiscountedPrice; readonly getDiscountedPrice = getDiscountedPrice;
readonly getBadgeClass = getBadgeClass;
private langService = inject(LanguageService);
itemName(product: Item): string { return getTranslatedField(product, 'name', this.langService.currentLanguage()); }
addToCart(event: Event, item: Item): void { addToCart(event: Event, item: Item): void {
event.preventDefault(); event.preventDefault();

View File

@@ -1,5 +1,5 @@
<div class="language-selector"> <div class="language-selector" role="listbox">
<button class="language-button" (click)="toggleDropdown()"> <button class="language-button" (click)="toggleDropdown()" (keydown)="onKeyDown($event)" aria-haspopup="listbox" [attr.aria-expanded]="dropdownOpen">
<img [src]="languageService.getCurrentLanguage()?.flagSvg" <img [src]="languageService.getCurrentLanguage()?.flagSvg"
[alt]="languageService.getCurrentLanguage()?.name" [alt]="languageService.getCurrentLanguage()?.name"
class="language-flag"> class="language-flag">
@@ -13,6 +13,8 @@
@for (lang of languageService.languages; track lang.code) { @for (lang of languageService.languages; track lang.code) {
<button <button
class="language-option" class="language-option"
role="option"
[attr.aria-selected]="languageService.currentLanguage() === lang.code"
[class.active]="languageService.currentLanguage() === lang.code" [class.active]="languageService.currentLanguage() === lang.code"
[class.disabled]="!lang.enabled" [class.disabled]="!lang.enabled"
[disabled]="!lang.enabled" [disabled]="!lang.enabled"
@@ -22,4 +24,25 @@
</button> </button>
} }
</div> </div>
<button class="currency-button" (click)="toggleCurrency()">
<span class="currency-symbol">{{ languageService.getCurrentCurrency()?.symbol }}</span>
<span class="currency-code">{{ languageService.currentCurrency() }}</span>
<svg class="dropdown-arrow" [class.rotated]="currencyOpen" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="currency-dropdown" [class.open]="currencyOpen">
@for (cur of languageService.currencies; track cur.code) {
<button
class="currency-option"
[class.active]="languageService.currentCurrency() === cur.code"
(click)="selectCurrency(cur)">
<span class="cur-symbol">{{ cur.symbol }}</span>
<span class="cur-name">{{ cur.name }}</span>
<span class="cur-code">{{ cur.code }}</span>
</button>
}
</div>
</div> </div>

View File

@@ -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); }
}
}

View File

@@ -1,5 +1,5 @@
import { Component, HostListener, ElementRef, ChangeDetectionStrategy } from '@angular/core'; import { Component, HostListener, ElementRef, ChangeDetectionStrategy } from '@angular/core';
import { LanguageService, Language } from '../../services/language.service'; import { LanguageService, Language, Currency } from '../../services/language.service';
@Component({ @Component({
selector: 'app-language-selector', selector: 'app-language-selector',
@@ -10,6 +10,7 @@ import { LanguageService, Language } from '../../services/language.service';
}) })
export class LanguageSelectorComponent { export class LanguageSelectorComponent {
dropdownOpen = false; dropdownOpen = false;
currencyOpen = false;
constructor( constructor(
public languageService: LanguageService, public languageService: LanguageService,
@@ -18,6 +19,12 @@ export class LanguageSelectorComponent {
toggleDropdown(): void { toggleDropdown(): void {
this.dropdownOpen = !this.dropdownOpen; this.dropdownOpen = !this.dropdownOpen;
this.currencyOpen = false;
}
toggleCurrency(): void {
this.currencyOpen = !this.currencyOpen;
this.dropdownOpen = false;
} }
selectLanguage(lang: Language): void { selectLanguage(lang: Language): void {
@@ -27,14 +34,30 @@ export class LanguageSelectorComponent {
} }
} }
selectCurrency(currency: Currency): void {
this.languageService.setCurrency(currency.code);
this.currencyOpen = false;
}
closeDropdown(): void { closeDropdown(): void {
this.dropdownOpen = false; this.dropdownOpen = false;
this.currencyOpen = false;
}
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']) @HostListener('document:click', ['$event'])
onClickOutside(event: Event): void { onClickOutside(event: Event): void {
if (!this.elementRef.nativeElement.contains(event.target)) { if (!this.elementRef.nativeElement.contains(event.target)) {
this.dropdownOpen = false; this.dropdownOpen = false;
this.currencyOpen = false;
} }
} }
} }

View 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;

View File

@@ -102,6 +102,10 @@ export const en: Translations = {
emailNeedsAt: 'Email must contain @', emailNeedsAt: 'Email must contain @',
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)', emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
emailInvalid: 'Invalid email format', emailInvalid: 'Invalid email format',
loginRequired: 'Log in to checkout',
loginRequiredDesc: 'Please log in via Telegram to place your order',
loginWithTelegram: 'Log in with Telegram',
orScanQr: 'Or scan the QR code',
}, },
search: { search: {
title: 'Product search', title: 'Product search',
@@ -134,6 +138,7 @@ export const en: Translations = {
emptyTitle: 'Oops! No subcategories yet', emptyTitle: 'Oops! No subcategories yet',
emptyDesc: 'There are no subcategories in this section yet, but they will appear soon', emptyDesc: 'There are no subcategories in this section yet, but they will appear soon',
goHome: 'Go home', goHome: 'Go home',
itemsInCategory: 'Items in this category',
}, },
itemDetail: { itemDetail: {
loading: 'Loading...', loading: 'Loading...',
@@ -148,6 +153,7 @@ export const en: Translations = {
mediumStock: 'Running low', mediumStock: 'Running low',
addToCart: 'Add to cart', addToCart: 'Add to cart',
description: 'Description', description: 'Description',
specifications: 'Specifications',
reviews: 'Reviews', reviews: 'Reviews',
yourReview: 'Your review', yourReview: 'Your review',
leaveReview: 'Leave a review', leaveReview: 'Leave a review',
@@ -169,6 +175,8 @@ export const en: Translations = {
yesterday: 'Yesterday', yesterday: 'Yesterday',
daysAgo: 'd. ago', daysAgo: 'd. ago',
weeksAgo: 'w. ago', weeksAgo: 'w. ago',
colour: 'Colour',
size: 'Size',
}, },
app: { app: {
connecting: 'Connecting to server...', connecting: 'Connecting to server...',

View File

@@ -6,7 +6,7 @@ export const hy: Translations = {
search: 'Որոնում', search: 'Որոնում',
about: 'Մեր մասին', about: 'Մեր մասին',
contacts: 'Կապ', contacts: 'Կապ',
searchPlaceholder: 'Որոնել...', searchPlaceholder: 'Փնտրել...',
catalog: 'Կատալոգ', catalog: 'Կատալոգ',
}, },
footer: { footer: {
@@ -14,7 +14,7 @@ export const hy: Translations = {
company: 'Ընկերություն', company: 'Ընկերություն',
aboutUs: 'Մեր մասին', aboutUs: 'Մեր մասին',
contacts: 'Կապ', contacts: 'Կապ',
requisites: ավերապայմաններ', requisites: ճարային տվյալներ',
support: 'Աջակցություն', support: 'Աջակցություն',
faq: 'ՀՏՀ', faq: 'ՀՏՀ',
delivery: 'Առաքում', delivery: 'Առաքում',
@@ -35,133 +35,139 @@ export const hy: Translations = {
}, },
home: { home: {
welcomeTo: 'Բարի գալուստ {{brand}}', welcomeTo: 'Բարի գալուստ {{brand}}',
subtitle: 'Գտեք ամեն ինչ մեկ վայրում', subtitle: 'Գտեք այն ամենը, ինչ պետք է՝ մեկ վայրում',
startSearch: 'Սկսել որոնումը', startSearch: 'Սկսել որոնումը',
loading: 'Կատեգորիաները բեռնվում են...', loading: 'Բեռնում ենք կատեգորիաները...',
errorTitle: 'Ինչ-որ բան սխալ է գնացել', errorTitle: 'Ինչ-որ բան սխալ գնաց',
retry: 'Փորձել կրկին', retry: 'Փորձել կրկին',
categoriesTitle: 'Ապրանքների կատեգորիաներ', categoriesTitle: 'Ապրանքների կատեգորիաներ',
categoriesSubtitle: 'Ընտրեք հետաքրքրող կատեգորիան', categoriesSubtitle: 'Ընտրեք ձեզ հետաքրքիր կատեգորիան',
categoriesEmpty: 'Կատեգորիաները շուտով կհայտնվեն', categoriesEmpty: 'Կատեգորիաները շուտով կհայտնվեն',
categoriesEmptyDesc: 'Մենք աշխատում ենք կատալոգի համալրման վրա', categoriesEmptyDesc: 'Մենք աշխատում ենք կատալոգի լրացման վրա',
dexarHeroTitle: 'Այստեղ դու կգտնես ամեն ինչ', dexarHeroTitle: 'Այստեղ կգտնես ամեն ինչ',
dexarHeroSubtitle: 'Հազարավոր ապրանքներ մեկ վայրում', dexarHeroSubtitle: 'Հազարավոր ապրանքներ մեկ վայրում',
dexarHeroTagline: 'պարզ և հարմար', dexarHeroTagline: 'պարզ և հարմար',
goToCatalog: 'Անցնել կատալոգ', goToCatalog: 'Գնալ կատալոգ',
findProduct: 'Գտնել ապրանք', findProduct: 'Գտնել ապրանք',
loadingDexar: 'Կատեգորիաները բեռնվում են...', loadingDexar: 'Կատեգորիաների բեռնում...',
catalogTitle: 'Ապրանքների կատալոգ', catalogTitle: 'Ապրանքների կատալոգ',
emptyCategoriesDexar: 'Կատեգորիաները դեռ չկան', emptyCategoriesDexar: 'Կատեգորիաները դեռ չկան',
categoriesSoonDexar: 'Շուտով այստեղ կհայտնվեն ապրանքների կատեգորիաներ', categoriesSoonDexar: 'Շուտով այստեղ կհայտնվեն կատեգորիաներ',
itemsCount: '{{count}} ապրանք', itemsCount: '{{count}} ապրանք',
}, },
cart: { cart: {
title: 'Զամբյուղ', title: 'Զամբյուղ',
clear: 'Մաքրել', clear: 'Մաքրել',
empty: 'Զամբյուղը դատարկ է', empty: 'Զամբյուղը դատարկ է',
emptyDesc: 'Ավելացրեք ապրանքներ գնումները սկսելու համար', emptyDesc: 'Ավելացրեք ապրանքներ՝ գնումները սկսելու համար',
goShopping: 'Անցնել գնումների', goShopping: 'Գնալ գնումների',
total: 'Ընդամենը', total: 'Ընդամենը',
items: 'Ապրանքներ', items: 'Ապրանքներ',
deliveryLabel: 'Առաքում', deliveryLabel: 'Առաքում',
toPay: 'Վճարման ենթակա', toPay: 'Վճարման ենթակա',
agreeWith: 'Ես համաձայն եմ', agreeWith: 'Ես համաձայն եմ',
publicOffer: 'հանրային օֆերտային', publicOffer: 'հանրային օֆերտայի',
returnPolicy: 'վերադարձի քաղաքականությանը', returnPolicy: 'վերադարձի քաղաքականության',
guaranteeTerms: 'երաշխիքային պայմաններին', guaranteeTerms: 'երաշխիքային պայմանների',
privacyPolicy: 'գաղտնիության քաղաքականությանը', privacyPolicy: 'գաղտնիության քաղաքականության',
and: 'և', and: 'և',
checkout: 'Ձևակերպել պատվեր', checkout: 'Ձևակերպել պատվերը',
close: 'Փակել', close: 'Փակել',
creatingPayment: 'Վճարումը ստեղծվում է...', creatingPayment: 'Վճարման ստեղծում...',
waitFewSeconds: 'Սպասեք մի քանի վայրկյան', waitFewSeconds: 'Խնդրում ենք սպասել մի քանի վայրկյան',
scanQr: 'Սկանավորեք QR կոդը վճարման համար', scanQr: 'Սքանավորեք QR կոդը վճարման համար',
amountToPay: 'Վճարման գումարը՝', amountToPay: 'Վճարման գումար՝',
waitingPayment: 'Սպասում ենք վճարմանը...', waitingPayment: 'Սպասում ենք վճարմանը...',
copied: '✓ Պատճենված է', copied: '✓ Պատճենված է',
copyLink: 'Պատճենել հղումը', copyLink: 'Պատճենել հղումը',
openNewTab: 'Բացել նոր ներդիրում', openNewTab: 'Բացել նոր ներդիրում',
paymentSuccess: 'Շնորհավորում ենք։ Վճարումը հաջողությամբ կատարվել է։', paymentSuccess: 'Շնորհավորում ենք! Վճարումը հաջող է անցել!',
paymentSuccessDesc: 'Մուտքագրեք ձեր կոնտակտային տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում', paymentSuccessDesc: 'Մուտքագրեք ձեր տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում',
sending: 'Ուղարկվում է...', sending: 'Ուղարկվում է...',
send: 'Ուղարկել', send: 'Ուղարկել',
paymentTimeout: 'Սպասման ժամանակը սպառվել է', paymentTimeout: 'Ժամանակը սպառվեց',
paymentTimeoutDesc: 'Մենք չենք ստացել վճարման հաստատում 3 րոպեի ընթացքում։', paymentTimeoutDesc: 'Մենք չստացանք վճարման հաստատում 3 րոպեի ընթացքում։',
autoClose: 'Պատուհանը կփակվի ավտոմատ...', autoClose: 'Պատուհանը կփակվի ավտոմատ...',
confirmClear: 'Համոզվա՞ծ եք, որ ցանկանում եք մաքրել զամբյուղը։', confirmClear: 'Վստա՞հ եք, որ ցանկանում եք մաքրել զամբյուղը',
acceptTerms: 'Խնդրում ենք ընդունել օֆերտայի, վերադարձի և երաշխիքի պայմանները պատվերը հաստատելու համար։', acceptTerms: 'Խնդրում ենք ընդունել պայմանները՝ պատվերը հաստատելու համար։',
copyError: 'Պատճենման սխալ՝', copyError: 'Պատճենման սխալ՝',
emailSuccess: 'Email-ը հաջողությամբ ուղարկվել է։ Ստուգեք ձեր փոստը։', emailSuccess: 'Email-ը հաջողությամբ ուղարկվեց։ Ստուգեք ձեր փոստը։',
emailError: 'Email ուղարկելու ժամանակ տեղի ունեցավ սխալ։ Խնդրում ենք փորձել կրկին։', emailError: 'Սխալ email ուղարկելիս։ Խնդրում ենք փորձել կրկին։',
phoneRequired: 'Հեռախոսահամարը պարտադիր է', phoneRequired: 'Հեռախոսահամարը պարտադիր է',
phoneMoreDigits: 'Մուտքագրեք ևս {{count}} թիվ', phoneMoreDigits: 'Մուտքագրեք ևս {{count}} թիվ',
phoneTooMany: 'Չափազանց շատ թվեր', phoneTooMany: 'Չափազանց շատ թվեր',
emailRequired: 'Email-ը պարտադիր է', emailRequired: 'Email-ը պարտադիր է',
emailTooShort: 'Email-ը չափազանց կարճ է (նվազագույնը 5 նիշ)', emailTooShort: 'Email-ը չափազանց կարճ է (առնվազն 5 նիշ)',
emailTooLong: 'Email-ը չափազանց երկար է (առավելագույնը 100 նիշ)', emailTooLong: 'Email-ը չափազանց երկար է (առավելագույնը 100 նիշ)',
emailNeedsAt: 'Email-ը պետք է պարունակի @ նշանը', emailNeedsAt: 'Email-ը պետք է պարունակի @',
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեն (.com, .ru և այլն)', emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
emailInvalid: 'Email ձևաչափը սխալ է', emailInvalid: 'Սխալ email ձևաչափ',
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
loginRequiredDesc: 'Պատվեր ձևակերպելու համար մուտք գործեք Telegram-ով',
loginWithTelegram: 'Մուտք Telegram-ով',
orScanQr: 'Կամ սքանավորեք QR կոդը',
}, },
search: { search: {
title: 'Ապրանքների որոնում', title: 'Ապրանքների որոնում',
placeholder: 'Մուտքագրեք ապրանքի անունը...', placeholder: 'Մուտքագրեք ապրանքի անվանումը...',
resultsCount: 'Գտնված ապրանքներ՝', resultsCount: 'Գտնված ապրանքներ՝',
searching: 'Որոնում...', searching: 'Որոնում...',
retry: 'Փորձել կրկին', retry: 'Փորձել կրկին',
noResults: 'Ոչինչ չի գտնվել', noResults: 'Ոչինչ չի գտնվել',
noResultsFor: '"{{query}}" հարցման համար ապրանքներ չեն գտնվել', noResultsFor: '"{{query}}" հարցմամբ ապրանքներ չեն գտնվել',
noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր', noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր',
addToCart: 'Ավելացնել զամբյուղ', addToCart: 'Ավելացնել զամբյուղ',
loadingMore: 'Բեռնվում է...', loadingMore: 'Բեռնում...',
allLoaded: 'Բոլոր արդյունքները բեռնված են', allLoaded: 'Բոլոր արդյունքները բեռնված են',
emptyState: 'Մուտքագրեք հարցում ապրանքներ որոնելու համար', emptyState: 'Մուտքագրեք հարցում որոնման համար',
of: 'ից', of: '-ից',
}, },
category: { category: {
retry: 'Փորձել կրկին', retry: 'Փորձել կրկին',
addToCart: 'Ավելացնել զամբյուղ', addToCart: 'Ավելացնել զամբյուղ',
loadingMore: 'Բեռնվում է...', loadingMore: 'Բեռնում...',
allLoaded: 'Բոլոր ապրանքները բեռնված են', allLoaded: 'Բոլոր ապրանքները բեռնված են',
emptyTitle: 'Ուպս։ Այստեղ դեռ դատարկ է', emptyTitle: 'Վա՜յ, այստեղ դեռ դատարկ է',
emptyDesc: 'Այս կատեգորիայում դեռ ապրանքներ չկան, բայց շուտով կհայտնվեն', emptyDesc: 'Այս կատեգորիայում դեռ ապրանքներ չկան',
goHome: 'Գլխավոր էջ', goHome: 'Գլխավոր',
loading: 'Ապրանքները բեռնվում են...', loading: 'Ապրանքների բեռնում...',
}, },
subcategories: { subcategories: {
loading: 'Ենթակատեգորիաները բեռնվում են...', loading: 'Ենթակատեգորիաների բեռնում...',
retry: 'Փորձել կրկին', retry: 'Փորձել կրկին',
emptyTitle: 'Ուպս։ Ենթակատեգորիաներ դեռ չկան', emptyTitle: 'Ենթակատեգորիաներ չկան',
emptyDesc: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան, բայց շուտով կհայտնվեն', emptyDesc: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան',
goHome: 'Գլխավոր էջ', goHome: 'Գլխավոր',
itemsInCategory: 'Ապրանքներ այս կատեգորիայում',
}, },
itemDetail: { itemDetail: {
loading: 'Բեռնվում է...', loading: 'Բեռնում...',
loadingDexar: 'Ապրանքը բեռնվում է...', loadingDexar: 'Ապրանքի բեռնում...',
back: 'Վերադառնալ', back: 'Վերադառնալ',
backHome: 'Վերադառնալ գլխավոր էջ', backHome: 'Վերադառնալ գլխավոր էջ',
noImage: 'Պատկեր չկա', noImage: 'Պատկեր չկա',
stock: 'Առկայություն՝', stock: 'Առկայություն՝',
inStock: 'Առկա է', inStock: 'Առկա է',
lowStock: 'Մնացել է քիչ', lowStock: 'Քիչ է մնացել',
lastItems: 'Վերջին հատերը', lastItems: 'Վերջին հատերը',
mediumStock: 'Վերջանում է', mediumStock: 'Ավարտվում է',
addToCart: 'Ավելացնել զամբյուղ', addToCart: 'Ավելացնել զամբյուղ',
description: 'Նկարագրություն', description: 'Նկարագրություն',
specifications: 'Բնութագրեր',
reviews: 'Կարծիքներ', reviews: 'Կարծիքներ',
yourReview: 'Ձեր կարծիքը', yourReview: 'Ձեր կարծիքը',
leaveReview: 'Թողնել կարծիք', leaveReview: 'Թողնել կարծիք',
rating: 'Գնահատական՝', rating: 'Գնահատական՝',
reviewPlaceholder: 'Կիսվեք ձեր տպավորություններով ապրանքի մասին...', reviewPlaceholder: 'Կիսվեք ձեր կարծիքով...',
reviewPlaceholderDexar: 'Կիսվեք ձեր տպավորություններով...', reviewPlaceholderDexar: 'Կիսվեք տպավորություններով...',
anonymous: 'Անանուն', anonymous: 'Անանուն',
submitting: 'Ուղարկվում է...', submitting: 'Ուղարկվում է...',
submit: 'Ուղարկել', submit: 'Ուղարկել',
reviewSuccess: 'Շնորհակալություն ձեր կարծիքի համար։', reviewSuccess: 'Շնորհակալություն ձեր կարծիքի համար!',
reviewError: 'Ուղարկման սխալ։ Փորձեք ավելի ուշ։', reviewError: 'Սխալ ուղարկելիս։ Փորձեք ավելի ուշ։',
defaultUser: 'Օգտատեր', defaultUser: 'Օգտատեր',
defaultUserDexar: 'Անանուն', defaultUserDexar: 'Անանուն',
noReviews: 'Դեռ կարծիքներ չկան։ Դարձեք առաջինը։', noReviews: 'Կարծիքներ դեռ չկան',
qna: 'Հարցեր և պատասխաններ', qna: 'Հարցեր և պատասխաններ',
photo: 'Լուսանկար', photo: 'Լուսանկար',
reviewsCount: 'կարծիք', reviewsCount: 'կարծիք',
@@ -169,34 +175,35 @@ export const hy: Translations = {
yesterday: 'Երեկ', yesterday: 'Երեկ',
daysAgo: 'օր առաջ', daysAgo: 'օր առաջ',
weeksAgo: 'շաբաթ առաջ', weeksAgo: 'շաբաթ առաջ',
colour: 'Գույն',
size: 'Չափ',
}, },
app: { app: {
connecting: 'Միացում սերվերին...', connecting: 'Կապ սերվերի հետ...',
serverUnavailable: 'Սերվերը հասանելի չէ', serverUnavailable: 'Սերվերը անհասանելի է',
serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետ կապը։', serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետը։',
retryConnection: 'Կրկնել փորձը', retryConnection: 'Փորձել կրկին',
pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս', pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս',
}, },
carousel: { carousel: {
loading: 'Ապրանքները բեռնվում են...', loading: 'Ապրանքների բեռնում...',
addToCart: 'Ավելացնել զամբյուղ', addToCart: 'Ավելացնել զամբյուղ',
}, },
common: { common: {
retry: 'Փորձել կրկին', retry: 'Փորձել կրկին',
loading: 'Բեռնվում է...', loading: 'Բեռնում...',
}, },
location: { location: {
allRegions: 'Բոլոր տարածաշրջաններ', allRegions: 'Բոլոր տարածաշրջանները',
chooseRegion: 'Ընտրեք տարածաշրջան', chooseRegion: 'Ընտրեք տարածաշրջանը',
detectAuto: 'Որոշել ինքնաշխատ', detectAuto: 'Որոշել ավտոմատ',
}, },
auth: { auth: {
loginRequired: 'Մուտք պահանջվում է', loginRequired: 'Պահանջվում է մուտք',
loginDescription: 'Պատվերի կատարման համար մուտք արեք Telegram-ի միջոցով', loginDescription: 'Պատվերի համար մուտք գործեք Telegram-ով',
checking: 'Ստուգում է...', checking: 'Ստուգում...',
loginWithTelegram: 'Մուտք գործել Telegram-ով', loginWithTelegram: 'Մուտք Telegram-ով',
orScanQr: 'Կամ սկանավորեք QR կոդը', orScanQr: 'Կամ սքանավորեք QR կոդը',
loginNote: 'Մուտքից հետո դուք կվերադառնավեք', loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
}, },
}; };

View File

@@ -102,6 +102,10 @@ export const ru: Translations = {
emailNeedsAt: 'Email должен содержать @', emailNeedsAt: 'Email должен содержать @',
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)', emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
emailInvalid: 'Некорректный формат email', emailInvalid: 'Некорректный формат email',
loginRequired: 'Войдите для оформления',
loginRequiredDesc: 'Для оформления заказа войдите через Telegram',
loginWithTelegram: 'Войти через Telegram',
orScanQr: 'Или отсканируйте QR-код',
}, },
search: { search: {
title: 'Поиск товаров', title: 'Поиск товаров',
@@ -134,6 +138,7 @@ export const ru: Translations = {
emptyTitle: 'Упс! Подкатегорий пока нет', emptyTitle: 'Упс! Подкатегорий пока нет',
emptyDesc: 'В этом разделе ещё нет подкатегорий, но скоро они появятся', emptyDesc: 'В этом разделе ещё нет подкатегорий, но скоро они появятся',
goHome: 'На главную', goHome: 'На главную',
itemsInCategory: 'Товары в этой категории',
}, },
itemDetail: { itemDetail: {
loading: 'Загрузка...', loading: 'Загрузка...',
@@ -148,6 +153,7 @@ export const ru: Translations = {
mediumStock: 'Заканчивается', mediumStock: 'Заканчивается',
addToCart: 'Добавить в корзину', addToCart: 'Добавить в корзину',
description: 'Описание', description: 'Описание',
specifications: 'Характеристики',
reviews: 'Отзывы', reviews: 'Отзывы',
yourReview: 'Ваш отзыв', yourReview: 'Ваш отзыв',
leaveReview: 'Оставить отзыв', leaveReview: 'Оставить отзыв',
@@ -169,6 +175,8 @@ export const ru: Translations = {
yesterday: 'Вчера', yesterday: 'Вчера',
daysAgo: 'дн. назад', daysAgo: 'дн. назад',
weeksAgo: 'нед. назад', weeksAgo: 'нед. назад',
colour: 'Цвет',
size: 'Размер',
}, },
app: { app: {
connecting: 'Подключение к серверу...', connecting: 'Подключение к серверу...',

View File

@@ -100,6 +100,10 @@ export interface Translations {
emailNeedsAt: string; emailNeedsAt: string;
emailNeedsDomain: string; emailNeedsDomain: string;
emailInvalid: string; emailInvalid: string;
loginRequired: string;
loginRequiredDesc: string;
loginWithTelegram: string;
orScanQr: string;
}; };
search: { search: {
title: string; title: string;
@@ -132,6 +136,7 @@ export interface Translations {
emptyTitle: string; emptyTitle: string;
emptyDesc: string; emptyDesc: string;
goHome: string; goHome: string;
itemsInCategory: string;
}; };
itemDetail: { itemDetail: {
loading: string; loading: string;
@@ -146,6 +151,7 @@ export interface Translations {
mediumStock: string; mediumStock: string;
addToCart: string; addToCart: string;
description: string; description: string;
specifications: string;
reviews: string; reviews: string;
yourReview: string; yourReview: string;
leaveReview: string; leaveReview: string;
@@ -167,6 +173,8 @@ export interface Translations {
yesterday: string; yesterday: string;
daysAgo: string; daysAgo: string;
weeksAgo: string; weeksAgo: string;
colour: string;
size: string;
}; };
app: { app: {
connecting: string; connecting: string;

View File

@@ -0,0 +1,50 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { LocationService } from '../services/location.service';
import { LanguageService } from '../services/language.service';
import { AuthService } from '../services/auth.service';
import { environment } from '../../environments/environment';
/** Map internal language codes to API header values */
const LANG_HEADER_MAP: Record<string, string> = {
'ru': 'RU',
'en': 'EN',
'hy': 'AM',
};
/** Map region IDs to API header values */
const REGION_HEADER_MAP: Record<string, string> = {
'moscow': 'Moscow',
'spb': 'ST. Petersburg',
'yerevan': 'Yerevan',
};
export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.startsWith(environment.apiUrl)) {
return next(req);
}
const locationService = inject(LocationService);
const languageService = inject(LanguageService);
const authService = inject(AuthService);
const regionId = locationService.regionId();
const lang = languageService.currentLanguage();
const currency = languageService.currentCurrency();
const session = authService.session();
let headers = req.headers;
if (regionId) {
headers = headers.set('X-Region', REGION_HEADER_MAP[regionId] ?? regionId);
}
if (lang) {
headers = headers.set('X-Language', LANG_HEADER_MAP[lang] ?? lang.toUpperCase());
}
headers = headers.set('Currency', currency || 'RUB');
if (session?.sessionId) {
headers = headers.set('WebSessionID', session.sessionId);
}
return next(req.clone({ headers }));
};

View File

@@ -2,8 +2,9 @@ import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { tap } from 'rxjs/operators'; 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 = new Map<string, { response: HttpResponse<unknown>, timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 минут
export const cacheInterceptor: HttpInterceptorFn = (req, next) => { export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
// Кэшируем только GET запросы // Кэшируем только GET запросы
@@ -11,12 +12,16 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
return next(req); return next(req);
} }
// Кэшируем только запросы списка категорий (не товары категорий) // Кэшируем списки категорий, товары категорий и отдельные товары
const shouldCache = req.url.match(/\/category$/) !== null; const isCategoryList = /\/category$/.test(req.url);
if (!shouldCache) { const isCategoryItems = /\/category\/\d+/.test(req.url);
const isItem = /\/items\/\d+/.test(req.url);
if (!isCategoryList && !isCategoryItems && !isItem) {
return next(req); return next(req);
} }
const ttl = isCategoryList ? CACHE_DURATION_MS : CATEGORY_CACHE_DURATION_MS;
// Cleanup expired entries before checking // Cleanup expired entries before checking
cleanupExpiredCache(); cleanupExpiredCache();
@@ -25,7 +30,7 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
// Проверяем наличие и актуальность кэша // Проверяем наличие и актуальность кэша
if (cachedResponse) { if (cachedResponse) {
const age = Date.now() - cachedResponse.timestamp; const age = Date.now() - cachedResponse.timestamp;
if (age < CACHE_DURATION) { if (age < ttl) {
return of(cachedResponse.response.clone()); return of(cachedResponse.response.clone());
} else { } else {
cache.delete(req.url); cache.delete(req.url);
@@ -53,7 +58,7 @@ export function clearCache(): void {
function cleanupExpiredCache(): void { function cleanupExpiredCache(): void {
const now = Date.now(); const now = Date.now();
for (const [url, data] of cache.entries()) { for (const [url, data] of cache.entries()) {
if (now - data.timestamp >= CACHE_DURATION) { if (now - data.timestamp >= CACHE_DURATION_MS) {
cache.delete(url); cache.delete(url);
} }
} }

View File

@@ -0,0 +1,788 @@
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: '2700K6500K' },
{ key: 'Совместимость', value: 'Алиса, Google Home, Alexa' }
],
descriptionFields: [
{ key: 'Яркость', value: '1100 лм' },
{ key: 'Цветовая t°', value: '2700K6500K' },
{ key: 'Совместимость', value: 'Алиса, Google Home, Alexa' }
],
subcategoryId: 'home',
translations: {},
comments: [],
callbacks: [],
questions: []
}
];
// ─── Helper ───
function getAllVisibleItems(): any[] {
return MOCK_ITEMS.filter(i => i.visible !== false);
}
function getItemsByCategoryId(categoryID: number): any[] {
return getAllVisibleItems().filter(i => i.categoryID === categoryID);
}
function respond<T>(body: T, delayMs = 150) {
return of(new HttpResponse({ status: 200, body })).pipe(delay(delayMs));
}
// ─── The Interceptor ───
export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
if (!(environment as any).useMockData) {
return next(req);
}
const url = req.url;
// ── GET /ping
if (url.endsWith('/ping') && req.method === 'GET') {
return respond({ message: 'pong (mock)' });
}
// ── GET /category (all categories flat list)
if (url.endsWith('/category') && req.method === 'GET') {
return respond(MOCK_CATEGORIES);
}
// ── GET /category/:id (items for a category)
const catItemsMatch = url.match(/\/category\/(\d+)$/);
if (catItemsMatch && req.method === 'GET') {
const catId = parseInt(catItemsMatch[1], 10);
const items = getItemsByCategoryId(catId);
return respond(items);
}
// ── GET /item/:id
const itemMatch = url.match(/\/item\/(\d+)$/);
if (itemMatch && req.method === 'GET') {
const itemId = parseInt(itemMatch[1], 10);
const item = MOCK_ITEMS.find(i => i.itemID === itemId);
if (item) {
return respond(item);
}
return of(new HttpResponse({ status: 404, body: { error: 'Item not found' } })).pipe(delay(100));
}
// ── GET /searchitems?search=...
if (url.includes('/searchitems') && req.method === 'GET') {
const search = req.params.get('search')?.toLowerCase() || '';
const items = getAllVisibleItems().filter(i =>
i.name.toLowerCase().includes(search) ||
i.simpleDescription?.toLowerCase().includes(search) ||
i.tags?.some((t: string) => t.toLowerCase().includes(search))
);
return respond({
items,
total: items.length,
count: items.length,
skip: 0
});
}
// ── GET /randomitems
if (url.includes('/randomitems') && req.method === 'GET') {
const count = parseInt(req.params.get('count') || '5', 10);
const shuffled = [...getAllVisibleItems()].sort(() => Math.random() - 0.5);
return respond(shuffled.slice(0, count));
}
// ── GET /cart (return empty)
if (url.endsWith('/cart') && req.method === 'GET') {
return respond([]);
}
// ── POST /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);
};

View File

@@ -1,3 +1,5 @@
import { ItemName } from './item.model';
export interface Category { export interface Category {
categoryID: number; categoryID: number;
name: string; name: string;
@@ -5,5 +7,32 @@ export interface Category {
icon?: string; icon?: string;
wideBanner?: string; wideBanner?: string;
itemCount?: number; itemCount?: number;
categoriesCount?: number;
priority?: 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;
} }

View File

@@ -5,6 +5,25 @@ export interface Photo {
type?: string; type?: string;
} }
export interface DescriptionField {
key: string;
value: string;
}
export interface Comment {
id?: string;
text: string;
author?: string;
stars?: number;
createdAt?: string;
}
export interface ItemTranslation {
name?: string;
simpleDescription?: string;
description?: DescriptionField[];
}
export interface Review { export interface Review {
rating?: number; rating?: number;
content?: string; content?: string;
@@ -21,6 +40,36 @@ export interface Question {
answer: string; answer: string;
upvotes: number; upvotes: number;
downvotes: 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 { export interface Item {
@@ -36,7 +85,30 @@ export interface Item {
rating: number; rating: number;
callbacks: Review[] | null; callbacks: Review[] | null;
questions: Question[] | null; questions: Question[] | null;
partnerID?: string; quantity?: number;
// Backend API fields
colour?: string;
size?: string;
language?: string;
names?: ItemName[];
descriptions?: ItemDescription[];
attributes?: ItemAttribute[];
// BackOffice API fields
id?: string;
visible?: boolean;
priority?: number;
imgs?: string[];
tags?: string[];
badges?: string[];
simpleDescription?: string;
descriptionFields?: DescriptionField[];
subcategoryId?: string;
translations?: Record<string, ItemTranslation>;
comments?: Comment[];
visits?: number;
itemDetails?: ItemDetail[];
} }
export interface CartItem extends Item { export interface CartItem extends Item {

View File

@@ -31,12 +31,12 @@
(touchstart)="onSwipeStart(item.itemID, $event)"> (touchstart)="onSwipeStart(item.itemID, $event)">
<div class="cart-item"> <div class="cart-item">
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-image"> <a [routerLink]="['/item', item.itemID] | langRoute" class="item-image">
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" /> <img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" />
</a> </a>
<div class="item-info"> <div class="item-info">
<div class="item-header"> <div class="item-header">
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-name">{{ item.name }}</a> <a [routerLink]="['/item', item.itemID] | langRoute" class="item-name">{{ itemName(item) }}</a>
<button class="remove-btn" (click)="removeItem(item.itemID)" title="Remove"> <button class="remove-btn" (click)="removeItem(item.itemID)" title="Remove">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/> <path d="M18 6L6 18M6 6l12 12"/>
@@ -44,17 +44,39 @@
</button> </button>
</div> </div>
<p class="item-description">{{ item.description.substring(0, 100) }}...</p> <p class="item-description">{{ itemDesc(item) || '' }}...</p>
@if (item.colour || (item.size && 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-footer">
<div class="item-pricing"> <div class="item-pricing">
@if (item.discount > 0) { @if (item.discount > 0) {
<div class="price-with-discount"> <div class="price-with-discount">
<span class="original-price">{{ item.price }} </span> <span class="original-price">{{ item.price }} {{ item.currency }}</span>
<span class="current-price">{{ getDiscountedPrice(item) | number:'1.2-2' }} </span> <span class="current-price">{{ getDiscountedPrice(item) | number:'1.2-2' }} {{ item.currency }}</span>
</div> </div>
} @else { } @else {
<span class="current-price">{{ item.price }} </span> <span class="current-price">{{ item.price }} {{ item.currency }}</span>
} }
</div> </div>
@@ -91,17 +113,17 @@
<div class="summary-row"> <div class="summary-row">
<span>{{ 'cart.items' | translate }} ({{ itemCount() }})</span> <span>{{ 'cart.items' | translate }} ({{ itemCount() }})</span>
<span class="value">{{ totalPrice() | number:'1.2-2' }} </span> <span class="value">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span>
</div> </div>
<div class="summary-row delivery"> <div class="summary-row delivery">
<span>{{ 'cart.deliveryLabel' | translate }}</span> <span>{{ 'cart.deliveryLabel' | translate }}</span>
<span>0 </span> <span>0 {{ currentCurrency }}</span>
</div> </div>
<div class="summary-row total"> <div class="summary-row total">
<span>{{ 'cart.toPay' | translate }}</span> <span>{{ 'cart.toPay' | translate }}</span>
<span class="total-price">{{ totalPrice() | number:'1.2-2' }} </span> <span class="total-price">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span>
</div> </div>
<div class="terms-agreement"> <div class="terms-agreement">
@@ -130,6 +152,36 @@
> >
{{ 'cart.checkout' | translate }} {{ 'cart.checkout' | translate }}
</button> </button>
@if (!isAuthenticated()) {
<div class="cart-login-gate">
<div class="login-gate-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
</div>
<p class="login-gate-title">{{ 'cart.loginRequired' | translate }}</p>
<p class="login-gate-desc">{{ 'cart.loginRequiredDesc' | translate }}</p>
<button class="telegram-login-btn" (click)="requestLogin()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
{{ 'cart.loginWithTelegram' | translate }}
</button>
<div class="login-gate-qr">
<p class="qr-hint">{{ 'cart.orScanQr' | translate }}</p>
<div class="qr-wrapper">
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=' + loginUrl()"
alt="QR Code"
width="150"
height="150"
loading="lazy" />
</div>
</div>
</div>
}
</div> </div>
</div> </div>
} }
@@ -166,7 +218,7 @@
<div class="payment-info"> <div class="payment-info">
<div class="payment-amount"> <div class="payment-amount">
<span class="label">{{ 'cart.amountToPay' | translate }}</span> <span class="label">{{ 'cart.amountToPay' | translate }}</span>
<span class="amount">{{ totalPrice() | number:'1.2-2' }} RUB</span> <span class="amount">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span>
</div> </div>
<div class="waiting-indicator"> <div class="waiting-indicator">
@@ -256,3 +308,5 @@
</div> </div>
</div> </div>
} }
<app-telegram-login />

View File

@@ -364,6 +364,35 @@
line-height: 1.5; 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 { .item-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -464,6 +493,35 @@
line-height: 1.6; 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 { .item-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -689,6 +747,85 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
.cart-login-gate {
margin-top: 16px;
padding: 20px;
border-radius: 14px;
background: rgba(42, 171, 238, 0.05);
border: 1px dashed rgba(42, 171, 238, 0.3);
text-align: center;
.login-gate-icon {
margin: 0 auto 10px;
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(42, 171, 238, 0.1);
color: #2AABEE;
display: flex;
align-items: center;
justify-content: center;
}
.login-gate-title {
margin: 0 0 4px;
font-size: 1rem;
font-weight: 700;
color: #1a1a1a;
}
.login-gate-desc {
margin: 0 0 16px;
font-size: 0.85rem;
color: #6b7280;
line-height: 1.4;
}
.telegram-login-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: 10px;
background: #2AABEE;
color: #fff;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #229ED9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
}
}
.login-gate-qr {
margin-top: 14px;
.qr-hint {
margin: 0 0 8px;
font-size: 0.8rem;
color: #999;
}
.qr-wrapper {
display: inline-flex;
padding: 10px;
background: #fff;
border-radius: 10px;
border: 1px solid #e5e7eb;
img {
display: block;
border-radius: 4px;
}
}
}
}
} }
// Novo Cart Summary - Green Modern // Novo Cart Summary - Green Modern

View File

@@ -7,15 +7,17 @@ import { Item, CartItem } from '../../models';
import { interval, Subscription } from 'rxjs'; import { interval, Subscription } from 'rxjs';
import { switchMap, take } from 'rxjs/operators'; import { switchMap, take } from 'rxjs/operators';
import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component'; import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component';
import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils'; import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service'; import { TranslateService } from '../../i18n/translate.service';
import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS, PAYMENT_ERROR_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants';
@Component({ @Component({
selector: 'app-cart', selector: 'app-cart',
imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, LangRoutePipe, TranslatePipe], imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe],
templateUrl: './cart.component.html', templateUrl: './cart.component.html',
styleUrls: ['./cart.component.scss'], styleUrls: ['./cart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -30,6 +32,9 @@ export class CartComponent implements OnDestroy {
private i18n = inject(TranslateService); private i18n = inject(TranslateService);
private authService = inject(AuthService); private authService = inject(AuthService);
isAuthenticated = this.authService.isAuthenticated;
loginUrl = signal('');
// Swipe state // Swipe state
swipedItemId = signal<number | null>(null); swipedItemId = signal<number | null>(null);
@@ -51,7 +56,7 @@ export class CartComponent implements OnDestroy {
emailSubmitting = signal<boolean>(false); emailSubmitting = signal<boolean>(false);
paidItems: CartItem[] = []; paidItems: CartItem[] = [];
maxChecks = 36; // 36 checks * 5 seconds = 180 seconds (3 minutes) maxChecks = PAYMENT_MAX_CHECKS;
private pollingSubscription?: Subscription; private pollingSubscription?: Subscription;
private closeTimeout?: ReturnType<typeof setTimeout>; private closeTimeout?: ReturnType<typeof setTimeout>;
@@ -64,6 +69,11 @@ export class CartComponent implements OnDestroy {
this.items = this.cartService.items; this.items = this.cartService.items;
this.itemCount = this.cartService.itemCount; this.itemCount = this.cartService.itemCount;
this.totalPrice = this.cartService.totalPrice; this.totalPrice = this.cartService.totalPrice;
this.loginUrl.set(this.authService.getTelegramLoginUrl());
}
requestLogin(): void {
this.authService.requestLogin();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -129,6 +139,11 @@ export class CartComponent implements OnDestroy {
readonly getMainImage = getMainImage; readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId; readonly trackByItemId = trackByItemId;
readonly getDiscountedPrice = getDiscountedPrice; readonly getDiscountedPrice = getDiscountedPrice;
readonly getBadgeClass = getBadgeClass;
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); }
get currentCurrency(): string { return this.langService.currentCurrency(); }
checkout(): void { checkout(): void {
if (!this.termsAccepted) { if (!this.termsAccepted) {
@@ -167,51 +182,62 @@ export class CartComponent implements OnDestroy {
} }
createPayment(): void { createPayment(): void {
const telegramUsername = this.getTelegramUsername(); const sessionId = this.authService.session()?.sessionId || '';
const userId = this.getUserId(); if (!sessionId) {
const orderId = this.generateOrderId(); this.paymentStatus.set('timeout');
return;
}
const paymentData = { // First sync cart items to server via websession, then create QR
amount: this.totalPrice(), const cartItems = this.items().map((item: CartItem) => ({
currency: 'RUB', itemID: item.itemID,
siteuserID: userId, quantity: item.quantity,
siteorderID: orderId, colour: item.colour || '',
redirectUrl: '', size: item.size || '',
telegramUsername: telegramUsername, price: item.discount > 0
items: this.items().map((item: CartItem) => ({ ? item.price * (1 - item.discount / 100)
itemID: item.itemID, : item.price,
price: item.discount > 0 }));
? item.price * (1 - item.discount / 100)
: item.price,
name: item.name,
quantity: item.quantity
}))
};
this.apiService.createPayment(paymentData).subscribe({ this.apiService.addToCart(sessionId, cartItems).subscribe({
next: (response) => { next: () => {
this.paymentId.set(response.qrId); this.apiService.createPayment(sessionId).subscribe({
this.qrCodeUrl.set(response.qrUrl); next: (response) => {
this.paymentUrl.set(response.payload); this.paymentId.set(response.qrId);
this.paymentStatus.set('waiting'); this.qrCodeUrl.set(response.qrUrl);
this.startPolling(); this.paymentUrl.set(response.Payload);
this.paymentStatus.set('waiting');
this.startPolling();
},
error: (err) => {
console.error('Error creating payment:', err);
this.paymentStatus.set('timeout');
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => {
this.closePaymentPopup();
}, PAYMENT_ERROR_CLOSE_MS);
}
});
}, },
error: (err) => { error: (err) => {
console.error('Error creating payment:', err); console.error('Error syncing cart:', err);
this.paymentStatus.set('timeout'); this.paymentStatus.set('timeout');
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => { this.closeTimeout = setTimeout(() => {
this.closePaymentPopup(); this.closePaymentPopup();
}, 4000); }, PAYMENT_ERROR_CLOSE_MS);
} }
}); });
} }
startPolling(): void { startPolling(): void {
this.pollingSubscription = interval(5000) // every 5 seconds this.stopPolling();
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
.pipe( .pipe(
take(this.maxChecks), // maximum 36 checks (3 minutes) take(this.maxChecks), // maximum 36 checks (3 minutes)
switchMap(() => { switchMap(() => {
return this.apiService.checkPaymentStatus(this.paymentId()); const sessionId = this.authService.session()?.sessionId || '';
return this.apiService.checkPaymentStatus(sessionId, this.paymentId());
}) })
) )
.subscribe({ .subscribe({
@@ -231,17 +257,19 @@ export class CartComponent implements OnDestroy {
if (this.paymentStatus() === 'waiting') { if (this.paymentStatus() === 'waiting') {
this.paymentStatus.set('timeout'); this.paymentStatus.set('timeout');
// Close popup after showing timeout message // Close popup after showing timeout message
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => { this.closeTimeout = setTimeout(() => {
this.closePaymentPopup(); this.closePaymentPopup();
}, 3000); }, PAYMENT_TIMEOUT_CLOSE_MS);
} }
}, },
error: (err) => { error: (err) => {
console.error('Error checking payment status:', err); console.error('Error checking payment status:', err);
// Continue checking even on error until time runs out // Continue checking even on error until time runs out
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => { this.closeTimeout = setTimeout(() => {
this.closePaymentPopup(); this.closePaymentPopup();
}, 3000); }, PAYMENT_TIMEOUT_CLOSE_MS);
} }
}); });
} }
@@ -257,34 +285,13 @@ export class CartComponent implements OnDestroy {
if (url) { if (url) {
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => {
this.linkCopied.set(true); this.linkCopied.set(true);
setTimeout(() => this.linkCopied.set(false), 2000); setTimeout(() => this.linkCopied.set(false), LINK_COPIED_DURATION_MS);
}).catch(err => { }).catch(err => {
console.error(this.i18n.t('cart.copyError'), err); console.error(this.i18n.t('cart.copyError'), err);
}); });
} }
} }
private getTelegramUsername(): string {
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
const user = window.Telegram.WebApp.initDataUnsafe.user;
return user.username || 'nontelegram';
}
return 'nontelegram';
}
private getUserId(): string {
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
return window.Telegram.WebApp.initDataUnsafe.user.id.toString();
}
return `web_${Date.now()}`;
}
private generateOrderId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `order_${timestamp}_${random}`;
}
submitEmail(): void { submitEmail(): void {
// Mark both fields as touched // Mark both fields as touched
this.emailTouched.set(true); this.emailTouched.set(true);

View File

@@ -9,17 +9,24 @@
@if (!error()) { @if (!error()) {
<div class="items-grid"> <div class="items-grid">
@for (item of items(); track trackByItemId($index, item)) { @for (item of items(); track trackByItemId($index, item)) {
<div class="item-card"> <div class="item-card" (mouseenter)="onItemHover(item.itemID)">
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link"> <a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
<div class="item-image"> <div class="item-image">
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" width="300" height="300" /> <img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
@if (item.discount > 0) { @if (item.discount > 0) {
<div class="discount-badge">-{{ item.discount }}%</div> <div class="discount-badge">-{{ item.discount }}%</div>
} }
@if (item.badges && item.badges.length > 0) {
<div class="item-badges-overlay">
@for (badge of item.badges; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
</div> </div>
<div class="item-details"> <div class="item-details">
<h3 class="item-name">{{ item.name }}</h3> <h3 class="item-name">{{ itemName(item) }}</h3>
<div class="item-rating"> <div class="item-rating">
<span class="rating-stars">⭐ {{ item.rating }}</span> <span class="rating-stars">⭐ {{ item.rating }}</span>
@@ -45,19 +52,29 @@
</div> </div>
</a> </a>
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)"> <button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)" [attr.aria-label]="('category.addToCart' | translate) + ': ' + item.name">
{{ 'category.addToCart' | translate }} {{ 'category.addToCart' | translate }}
</button> </button>
</div> </div>
} }
</div>
@if (loading() && items().length > 0) { @if (loading() && items().length > 0) {
<div class="loading-more"> @for (i of skeletonSlots; track i) {
<div class="spinner"></div> <div class="item-card skeleton-card">
<p>{{ 'category.loadingMore' | translate }}</p> <div class="item-link">
</div> <div class="item-image skeleton-image"></div>
} <div class="item-details">
<div class="skeleton-line skeleton-title"></div>
<div class="skeleton-line skeleton-rating"></div>
<div class="skeleton-line skeleton-price"></div>
<div class="skeleton-line skeleton-stock"></div>
</div>
</div>
<div class="skeleton-btn"></div>
</div>
}
}
</div>
@if (!hasMore() && items().length > 0) { @if (!hasMore() && items().length > 0) {
<div class="no-more"> <div class="no-more">

View File

@@ -95,7 +95,7 @@
.items-grid { .items-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 30px; gap: 30px;
margin-bottom: 40px; margin-bottom: 40px;
width: 100%; width: 100%;
@@ -103,8 +103,10 @@
.item-card { .item-card {
width: 100%; width: 100%;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover { &:hover {
@@ -139,7 +141,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #f5f5f5; background: #f0f0f0;
img { img {
width: 100%; width: 100%;
@@ -147,7 +149,7 @@
object-fit: contain; object-fit: contain;
background: white; background: white;
padding: 12px; padding: 12px;
transition: transform 0.3s ease; transition: transform 0.3s ease, opacity 0.3s ease;
} }
&:hover img { &:hover img {
@@ -192,6 +194,7 @@
margin: 0; margin: 0;
line-height: 1.3; line-height: 1.3;
display: -webkit-box; display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
@@ -287,11 +290,6 @@
} }
} }
.loading-more {
text-align: center;
padding: 40px 20px;
}
.spinner { .spinner {
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -312,24 +310,77 @@
padding: 40px 20px; padding: 40px 20px;
} }
// Skeleton loading cards
.skeleton-card {
pointer-events: none;
.skeleton-image {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line {
border-radius: 6px;
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-title {
height: 16px;
width: 80%;
}
.skeleton-rating {
height: 12px;
width: 50%;
}
.skeleton-price {
height: 18px;
width: 40%;
margin-top: auto;
}
.skeleton-stock {
height: 6px;
width: 60px;
}
.skeleton-btn {
height: 42px;
background: linear-gradient(90deg, #5a8a85 25%, #497671 50%, #5a8a85 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0 0 13px 13px;
margin-top: -1px;
}
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// Responsive // Responsive
@media (max-width: 1200px) { @media (max-width: 1200px) {
.items-grid { .items-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px; gap: 24px;
} }
} }
@media (max-width: 992px) { @media (max-width: 992px) {
.items-grid { .items-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px; gap: 20px;
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.items-grid { .items-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px; gap: 16px;
} }
@@ -353,7 +404,7 @@
} }
.items-grid { .items-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }

View File

@@ -1,12 +1,15 @@
import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStrategy } from '@angular/core'; import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStrategy, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService } from '../../services'; import { ApiService, CartService } from '../../services';
import { PrefetchService } from '../../services/prefetch.service';
import { Item } from '../../models'; import { Item } from '../../models';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils'; import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
import { LanguageService } from '../../services/language.service';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS, ITEMS_PER_PAGE } from '../../config/constants';
@Component({ @Component({
selector: 'app-category', selector: 'app-category',
@@ -23,7 +26,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
hasMore = signal(true); hasMore = signal(true);
private skip = 0; private skip = 0;
private readonly count = 20; private readonly count = ITEMS_PER_PAGE;
private isLoadingMore = false; private isLoadingMore = false;
private routeSubscription?: Subscription; private routeSubscription?: Subscription;
private scrollTimeout?: ReturnType<typeof setTimeout>; private scrollTimeout?: ReturnType<typeof setTimeout>;
@@ -31,7 +34,8 @@ export class CategoryComponent implements OnInit, OnDestroy {
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private apiService: ApiService, private apiService: ApiService,
private cartService: CartService private cartService: CartService,
private prefetchService: PrefetchService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -90,12 +94,12 @@ export class CategoryComponent implements OnInit, OnDestroy {
this.scrollTimeout = setTimeout(() => { this.scrollTimeout = setTimeout(() => {
const scrollPosition = window.innerHeight + window.scrollY; const scrollPosition = window.innerHeight + window.scrollY;
const bottomPosition = document.documentElement.scrollHeight - 500; const bottomPosition = document.documentElement.scrollHeight - SCROLL_THRESHOLD_PX;
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore() && !this.isLoadingMore) { if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore() && !this.isLoadingMore) {
this.loadItems(); this.loadItems();
} }
}, 100); }, SCROLL_DEBOUNCE_MS);
} }
addToCart(itemID: number, event: Event): void { addToCart(itemID: number, event: Event): void {
@@ -104,7 +108,16 @@ export class CategoryComponent implements OnInit, OnDestroy {
this.cartService.addItem(itemID); this.cartService.addItem(itemID);
} }
onItemHover(itemID: number): void {
this.prefetchService.prefetchItem(itemID);
}
readonly skeletonSlots = Array.from({ length: 8 });
readonly getDiscountedPrice = getDiscountedPrice; readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage; readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId; readonly trackByItemId = trackByItemId;
readonly getBadgeClass = getBadgeClass;
private langService = inject(LanguageService);
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
} }

View File

@@ -18,24 +18,94 @@
<h2>{{ parentName() }}</h2> <h2>{{ parentName() }}</h2>
</header> </header>
<!-- Nested subcategories from API (backOffice format with hasItems) -->
@if (nestedSubcategories().length > 0) {
<div class="categories-grid">
@for (sub of nestedSubcategories(); track trackBySubId($index, sub)) {
<a [routerLink]="['/category', sub.id] | langRoute" class="category-card">
<div class="category-image">
@if (sub.img) {
<img [src]="sub.img" [alt]="sub.name" loading="lazy" decoding="async" />
} @else {
<div class="category-fallback">{{ sub.name.charAt(0) }}</div>
}
</div>
<div class="category-info">
<h3 class="category-name">{{ sub.name }}</h3>
@if (sub.itemCount) {
<span class="category-count">{{ sub.itemCount }}</span>
}
</div>
</a>
}
</div>
}
<!-- Legacy flat subcategories -->
@if (subcategories().length > 0) { @if (subcategories().length > 0) {
<div class="categories-grid"> <div class="categories-grid">
@for (cat of subcategories(); track trackByCategoryId($index, cat)) { @for (cat of subcategories(); track trackByCategoryId($index, cat)) {
<a [routerLink]="['/category', cat.categoryID] | langRoute" class="category-card"> <a [routerLink]="['/category', cat.categoryID] | langRoute" class="category-card">
<div class="category-image"> <div class="category-image">
@if (cat.icon) { @if (cat.icon) {
<img [src]="cat.icon" [alt]="cat.name" loading="lazy" decoding="async" /> <img [src]="cat.icon" [alt]="categoryName(cat)" loading="lazy" decoding="async" />
} @else { } @else {
<div class="category-fallback">{{ cat.name.charAt(0) }}</div> <div class="category-fallback">{{ categoryName(cat).charAt(0) }}</div>
} }
</div> </div>
<div class="category-info"> <div class="category-info">
<h3 class="category-name">{{ cat.name }}</h3> <h3 class="category-name">{{ categoryName(cat) }}</h3>
</div> </div>
</a> </a>
} }
</div> </div>
} @else { }
<!-- Items directly in this category -->
@if (categoryItems().length > 0) {
<div class="category-items-section">
<h3 class="items-section-title">{{ 'subcategories.itemsInCategory' | translate }}</h3>
<div class="items-grid">
@for (item of categoryItems(); track trackByItemId($index, item)) {
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-card">
<div class="item-image">
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" />
@if (item.discount > 0) {
<span class="item-discount">-{{ item.discount }}%</span>
}
@if (item.badges && item.badges.length > 0) {
<div class="item-badges">
@for (badge of item.badges; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
</div>
<div class="item-info">
<h4 class="item-name">{{ itemName(item) }}</h4>
<div class="item-price">
@if (item.discount > 0) {
<span class="old-price">{{ item.price }} {{ item.currency }}</span>
<span class="current-price">{{ getDiscountedPrice(item) | number:'1.0-0' }} {{ item.currency }}</span>
} @else {
<span class="current-price">{{ item.price }} {{ item.currency }}</span>
}
</div>
<button class="item-cart-btn" (click)="addToCart(item.itemID, $event)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>
</button>
</div>
</a>
}
</div>
</div>
}
@if (!hasSubcategories() && categoryItems().length === 0) {
<div class="no-subcats"> <div class="no-subcats">
<div class="no-subcats-icon"> <div class="no-subcats-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -235,6 +235,149 @@
min-height: calc(2 * 1.3em); min-height: calc(2 * 1.3em);
} }
.category-count {
font-family: "DM Sans", sans-serif;
font-size: 0.8rem;
color: #697777;
}
// Items section within subcategories page
.category-items-section {
margin-top: 40px;
.items-section-title {
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 1.5rem;
font-weight: 700;
color: #1e3c38;
margin: 0 0 20px 0;
}
}
.items-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.item-card {
display: flex;
flex-direction: column;
text-decoration: none;
border: 1px solid #d3dad9;
border-radius: 13px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #fff;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
}
.item-image {
position: relative;
aspect-ratio: 1;
overflow: hidden;
background: #f5f5f5;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.item-card:hover & img {
transform: scale(1.05);
}
.item-discount {
position: absolute;
top: 8px;
right: 8px;
background: #dc2626;
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 6px;
}
.item-badges {
position: absolute;
top: 8px;
left: 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.item-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
}
}
.item-info {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-name {
font-family: "DM Sans", sans-serif;
font-size: 0.9rem;
font-weight: 600;
color: #1e3c38;
margin: 0;
line-height: 1.3;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.item-price {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.old-price {
font-size: 0.8rem;
color: #a1b4b5;
text-decoration: line-through;
}
.current-price {
font-size: 1rem;
font-weight: 700;
color: #1e3c38;
}
}
.item-cart-btn {
align-self: flex-end;
background: #497671;
color: white;
border: none;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #3a5f5b;
}
}
// Keyframes // Keyframes
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
@@ -248,6 +391,11 @@
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 24px; gap: 24px;
} }
.items-grid {
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
} }
@media (max-width: 992px) { @media (max-width: 992px) {
@@ -273,6 +421,11 @@
gap: 16px; gap: 16px;
} }
.items-grid {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.category-info { .category-info {
padding: 10px 12px; padding: 10px 12px;
} }
@@ -294,6 +447,11 @@
gap: 12px; gap: 12px;
} }
.items-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.category-info { .category-info {
padding: 8px 10px; padding: 8px 10px;
} }

View File

@@ -1,15 +1,17 @@
import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ApiService, LanguageService } from '../../services'; import { ApiService, CartService, LanguageService } from '../../services';
import { Category } from '../../models'; import { Category, Item, Subcategory } from '../../models';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service'; import { TranslateService } from '../../i18n/translate.service';
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField, getTranslatedCategoryName } from '../../utils/item.utils';
@Component({ @Component({
selector: 'app-subcategories', selector: 'app-subcategories',
imports: [RouterLink, LangRoutePipe, TranslatePipe], imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe],
templateUrl: './subcategories.component.html', templateUrl: './subcategories.component.html',
styleUrls: ['./subcategories.component.scss'], styleUrls: ['./subcategories.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -17,6 +19,10 @@ import { TranslateService } from '../../i18n/translate.service';
export class SubcategoriesComponent implements OnInit, OnDestroy { export class SubcategoriesComponent implements OnInit, OnDestroy {
categories = signal<Category[]>([]); categories = signal<Category[]>([]);
subcategories = signal<Category[]>([]); subcategories = signal<Category[]>([]);
/** Nested subcategories from API with hasItems support */
nestedSubcategories = signal<Subcategory[]>([]);
/** Items belonging directly to this category (when hasItems is true) */
categoryItems = signal<Item[]>([]);
loading = signal(true); loading = signal(true);
error = signal<string | null>(null); error = signal<string | null>(null);
@@ -29,7 +35,8 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private apiService: ApiService, private apiService: ApiService,
private langService: LanguageService private langService: LanguageService,
private cartService: CartService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -45,19 +52,40 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
private loadForParent(parentID: number): void { private loadForParent(parentID: number): void {
this.loading.set(true); this.loading.set(true);
this.categoryItems.set([]);
this.nestedSubcategories.set([]);
this.apiService.getCategories().subscribe({ this.apiService.getCategories().subscribe({
next: (cats) => { next: (cats) => {
this.categories.set(cats); this.categories.set(cats);
const subs = cats.filter(c => c.parentID === parentID);
const parent = cats.find(c => c.categoryID === parentID); const parent = cats.find(c => c.categoryID === parentID);
this.parentName.set(parent ? parent.name : this.i18n.t('home.categoriesTitle')); this.parentName.set(parent ? getTranslatedCategoryName(parent, this.langService.currentLanguage()) : this.i18n.t('home.categoriesTitle'));
if (!subs || subs.length === 0) { // Check for nested subcategories from API response (backOffice format)
const nested = parent?.subcategories || [];
const visibleNested = nested.filter(s => s.visible !== false);
// Also check flat legacy subcategories
const flatSubs = cats.filter(c => c.parentID === parentID);
if (visibleNested.length > 0) {
// Use nested subcategories from API
this.nestedSubcategories.set(visibleNested);
this.subcategories.set([]);
// If this category itself has items, load them too
this.loadCategoryItems(parentID);
} else if (flatSubs.length > 0) {
// Legacy flat subcategories
this.subcategories.set(flatSubs);
this.nestedSubcategories.set([]);
// Also load items for this category in case it has direct items
this.loadCategoryItems(parentID);
} else {
// No subcategories: redirect to items list for this category // No subcategories: redirect to items list for this category
const lang = this.langService.currentLanguage(); const lang = this.langService.currentLanguage();
this.router.navigate([`/${lang}/category`, parentID, 'items'], { replaceUrl: true }); this.router.navigate([`/${lang}/category`, parentID, 'items'], { replaceUrl: true });
} else {
this.subcategories.set(subs);
} }
this.loading.set(false); this.loading.set(false);
@@ -70,8 +98,43 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
}); });
} }
/** Load items that belong directly to this category */
private loadCategoryItems(categoryID: number): void {
this.apiService.getCategoryItems(categoryID, 50, 0).subscribe({
next: (items) => {
this.categoryItems.set(items);
},
error: () => {
// Not critical — subcategories still work
}
});
}
hasSubcategories(): boolean {
return this.subcategories().length > 0 || this.nestedSubcategories().length > 0;
}
addToCart(itemID: number, event: Event): void {
event.preventDefault();
event.stopPropagation();
this.cartService.addItem(itemID);
}
// TrackBy function for performance optimization // TrackBy function for performance optimization
trackByCategoryId(index: number, category: Category): number { trackByCategoryId(index: number, category: Category): number {
return category.categoryID; return category.categoryID;
} }
trackBySubId(index: number, sub: Subcategory): string {
return sub.id;
}
readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId;
readonly getBadgeClass = getBadgeClass;
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
categoryName(cat: Category): string { return getTranslatedCategoryName(cat, this.langService.currentLanguage()); }
} }

View File

@@ -19,10 +19,23 @@
<app-items-carousel /> <app-items-carousel />
@if (loading()) { @if (loading()) {
<div class="novo-loading"> <section class="novo-categories">
<div class="novo-spinner"></div> <div class="novo-section-header">
<p>{{ 'home.loading' | translate }}</p> <div class="skeleton-line" style="height: 32px; width: 200px; margin: 0 auto 12px;"></div>
</div> <div class="skeleton-line" style="height: 18px; width: 300px; margin: 0 auto;"></div>
</div>
<div class="novo-categories-grid">
@for (i of skeletonSlots; track i) {
<div class="novo-category-card skeleton-card">
<div class="novo-category-image skeleton-image"></div>
<div class="novo-category-info">
<div class="skeleton-line" style="height: 18px; width: 70%;"></div>
<div class="skeleton-line" style="height: 18px; width: 20px;"></div>
</div>
</div>
}
</div>
</section>
} }
@if (error()) { @if (error()) {
@@ -53,15 +66,15 @@
<a [routerLink]="['/category', category.categoryID] | langRoute" class="novo-category-card"> <a [routerLink]="['/category', category.categoryID] | langRoute" class="novo-category-card">
<div class="novo-category-image"> <div class="novo-category-image">
@if (category.icon) { @if (category.icon) {
<img [src]="category.icon" [alt]="category.name" loading="lazy" /> <img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" />
} @else { } @else {
<div class="novo-category-placeholder"> <div class="novo-category-placeholder">
<span>{{ category.name.charAt(0) }}</span> <span>{{ categoryName(category).charAt(0) }}</span>
</div> </div>
} }
</div> </div>
<div class="novo-category-info"> <div class="novo-category-info">
<h3>{{ category.name }}</h3> <h3>{{ categoryName(category) }}</h3>
<span class="novo-category-arrow"></span> <span class="novo-category-arrow"></span>
</div> </div>
</a> </a>
@@ -101,10 +114,20 @@
<app-items-carousel /> <app-items-carousel />
@if (loading()) { @if (loading()) {
<div class="dexar-loading"> <section class="dexar-categories">
<div class="dexar-spinner"></div> <div class="skeleton-line" style="height: 36px; width: 220px; margin-bottom: 40px;"></div>
<p>{{ 'home.loadingDexar' | translate }}</p> <div class="dexar-categories-grid">
</div> @for (i of skeletonSlots; track i) {
<div class="dexar-category-card skeleton-card">
<div class="dexar-category-image skeleton-image"></div>
<div class="dexar-category-info">
<div class="skeleton-line" style="height: 16px; width: 75%;"></div>
<div class="skeleton-line" style="height: 12px; width: 40%; margin-top: 4px;"></div>
</div>
</div>
}
</div>
</section>
} }
@if (error()) { @if (error()) {
@@ -131,15 +154,15 @@
[class.dexar-category-card--wide]="isWideCategory(category.categoryID)"> [class.dexar-category-card--wide]="isWideCategory(category.categoryID)">
<div class="dexar-category-image"> <div class="dexar-category-image">
@if (isWideCategory(category.categoryID) && category.wideBanner) { @if (isWideCategory(category.categoryID) && category.wideBanner) {
<img [src]="category.wideBanner" [alt]="category.name" loading="lazy" decoding="async" /> <img [src]="category.wideBanner" [alt]="categoryName(category)" loading="lazy" decoding="async" />
} @else if (category.icon) { } @else if (category.icon) {
<img [src]="category.icon" [alt]="category.name" loading="lazy" decoding="async" /> <img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" decoding="async" />
} @else { } @else {
<div class="dexar-category-fallback">{{ category.name.charAt(0) }}</div> <div class="dexar-category-fallback">{{ categoryName(category).charAt(0) }}</div>
} }
</div> </div>
<div class="dexar-category-info"> <div class="dexar-category-info">
<h3 class="dexar-category-name">{{ category.name }}</h3> <h3 class="dexar-category-name">{{ categoryName(category) }}</h3>
<p class="dexar-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.categoryID) } }}</p> <p class="dexar-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.categoryID) } }}</p>
</div> </div>
</a> </a>

View File

@@ -896,3 +896,26 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
} }
// Skeleton loading cards
.skeleton-card {
pointer-events: none;
}
.skeleton-image {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line {
border-radius: 6px;
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

View File

@@ -1,8 +1,9 @@
import { Component, OnInit, signal, computed, ChangeDetectionStrategy } from '@angular/core'; import { Component, OnInit, OnDestroy, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { ApiService, LanguageService } from '../../services'; import { ApiService, LanguageService } from '../../services';
import { Category } from '../../models'; import { Category } from '../../models';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { getTranslatedCategoryName } from '../../utils/item.utils';
import { ItemsCarouselComponent } from '../../components/items-carousel/items-carousel.component'; import { ItemsCarouselComponent } from '../../components/items-carousel/items-carousel.component';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
@@ -14,13 +15,14 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
styleUrls: ['./home.component.scss'], styleUrls: ['./home.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class HomeComponent implements OnInit { export class HomeComponent implements OnInit, OnDestroy {
brandName = environment.brandFullName; brandName = environment.brandFullName;
isnovo = environment.theme === 'novo'; isnovo = environment.theme === 'novo';
categories = signal<Category[]>([]); categories = signal<Category[]>([]);
wideCategories = signal<Set<number>>(new Set()); wideCategories = signal<Set<number>>(new Set());
loading = signal(true); loading = signal(true);
error = signal<string | null>(null); error = signal<string | null>(null);
readonly skeletonSlots = Array.from({ length: 6 });
// Memoized computed values for performance // Memoized computed values for performance
topLevelCategories = computed(() => { topLevelCategories = computed(() => {
@@ -56,6 +58,14 @@ export class HomeComponent implements OnInit {
this.loadCategories(); this.loadCategories();
} }
ngOnDestroy(): void {
this.pendingImages.forEach(img => {
img.onload = null;
img.onerror = null;
});
this.pendingImages.clear();
}
loadCategories(): void { loadCategories(): void {
this.loading.set(true); this.loading.set(true);
this.apiService.getCategories().subscribe({ this.apiService.getCategories().subscribe({
@@ -84,13 +94,17 @@ export class HomeComponent implements OnInit {
return this.wideCategories().has(categoryID); return this.wideCategories().has(categoryID);
} }
private pendingImages = new Set<HTMLImageElement>();
private detectWideImages(categories: Category[]): void { private detectWideImages(categories: Category[]): void {
const topLevel = categories.filter(c => c.parentID === 0); const topLevel = categories.filter(c => c.parentID === 0);
topLevel.forEach(cat => { topLevel.forEach(cat => {
if (!cat.wideBanner) return; if (!cat.wideBanner) return;
const img = new Image(); const img = new Image();
this.pendingImages.add(img);
img.onload = () => { img.onload = () => {
this.pendingImages.delete(img);
const ratio = img.naturalWidth / img.naturalHeight; const ratio = img.naturalWidth / img.naturalHeight;
if (ratio > 2) { if (ratio > 2) {
this.wideCategories.update(set => { this.wideCategories.update(set => {
@@ -100,6 +114,7 @@ export class HomeComponent implements OnInit {
}); });
} }
}; };
img.onerror = () => this.pendingImages.delete(img);
img.src = cat.wideBanner; img.src = cat.wideBanner;
}); });
} }
@@ -109,6 +124,10 @@ export class HomeComponent implements OnInit {
this.router.navigate([`/${lang}/search`]); this.router.navigate([`/${lang}/search`]);
} }
categoryName(cat: Category): string {
return getTranslatedCategoryName(cat, this.langService.currentLanguage());
}
scrollToCatalog(): void { scrollToCatalog(): void {
const target = document.getElementById('catalog'); const target = document.getElementById('catalog');
if (!target) return; if (!target) return;

View File

@@ -1,4 +1,4 @@
<div class="legal-page"> <div class="legal-page">
<div class="legal-container"> <div class="legal-container">
<h1>About the company LLC «INT FIN LOGISTIC»</h1> <h1>About the company LLC «INT FIN LOGISTIC»</h1>
@@ -65,7 +65,7 @@
<p><strong>Director:</strong> Оганнисян Ашот Рафикович</p> <p><strong>Director:</strong> Оганнисян Ашот Рафикович</p>
<p><strong>Legal address:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p> <p><strong>Legal address:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
<p><strong>Office in Armenia:</strong><br>0033, Ереван, улица Братьев Орбели, 47</p> <p><strong>Office in Armenia:</strong><br>0033, Ереван, улица Братьев Орбели, 47</p>
<p><strong>Office in Russia:</strong><br>121059, Москва, наб. Тараса Шевченко, 3к2</p>
<p><strong>Key details:</strong><br>ИНН (RF): 9909697628<br>ИНН (Armenia): 03033502<br>КПП: 770287001<br>ОГРН: 85.110.1408711</p> <p><strong>Key details:</strong><br>ИНН (RF): 9909697628<br>ИНН (Armenia): 03033502<br>КПП: 770287001<br>ОГРН: 85.110.1408711</p>
<p><strong>Banking details:</strong><br>Bank: АО "Райффайзенбанк"<br>Settlement account: 40807810500000002376<br>Correspondent account: 30101810200000000700<br>БИК: 044525700</p> <p><strong>Banking details:</strong><br>Bank: АО "Райффайзенбанк"<br>Settlement account: 40807810500000002376<br>Correspondent account: 30101810200000000700<br>БИК: 044525700</p>
<p><strong>Contact information:</strong><br>Phone (Russia): +7 (926) 459-31-57<br>Phone (Armenia): +374 94 86 18 16<br>Email: info&#64;dexarmarket.ru<br>Website: www.dexarmarket.ru</p> <p><strong>Contact information:</strong><br>Phone (Russia): +7 (926) 459-31-57<br>Phone (Armenia): +374 94 86 18 16<br>Email: info&#64;dexarmarket.ru<br>Website: www.dexarmarket.ru</p>

View File

@@ -1,4 +1,4 @@
<div class="legal-page"> <div class="legal-page">
<div class="legal-container"> <div class="legal-container">
<h1>«ИНТ ФИН ЛОГИСТИК» ՍՊԸ ընկերության մասին</h1> <h1>«ИНТ ФИН ЛОГИСТИК» ՍՊԸ ընկերության մասին</h1>
@@ -65,7 +65,6 @@
<p><strong>Տնօրեն՝</strong> Оганнисян Ашот Рафикович</p> <p><strong>Տնօրեն՝</strong> Оганнисян Ашот Рафикович</p>
<p><strong>Իրավաբանական հասցե՝</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p> <p><strong>Իրավաբանական հասցե՝</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
<p><strong>Գրասենյակ Հայաստանում՝</strong><br>0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</p> <p><strong>Գրասենյակ Հայաստանում՝</strong><br>0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</p>
<p><strong>Գրասենյակ Ռուսաստանում՝</strong><br>121059, Москва, наб. Тараса Шевченко, 3к2</p>
<p><strong>Հիմնական վավերապայմանները՝</strong><br>ՀՍՀ (ՌՄ)՝ 9909697628<br>ՀՍՀ (Հայաստան)՝ 03033502<br>ԿՊՊ՝ 770287001<br>ՕԳՌՆ՝ 85.110.1408711</p> <p><strong>Հիմնական վավերապայմանները՝</strong><br>ՀՍՀ (ՌՄ)՝ 9909697628<br>ՀՍՀ (Հայաստան)՝ 03033502<br>ԿՊՊ՝ 770287001<br>ՕԳՌՆ՝ 85.110.1408711</p>
<p><strong>Բանկային վավերապայմանները՝</strong><br>Բանկ՝ АО "Райффайзенбанк"<br>Հաշվարկային հաշիվ՝ 40807810500000002376<br>Թղթակցային հաշիվ՝ 30101810200000000700<br>ԲԻԿ՝ 044525700</p> <p><strong>Բանկային վավերապայմանները՝</strong><br>Բանկ՝ АО "Райффайзенбанк"<br>Հաշվարկային հաշիվ՝ 40807810500000002376<br>Թղթակցային հաշիվ՝ 30101810200000000700<br>ԲԻԿ՝ 044525700</p>
<p><strong>Կապի տեղեկատվություն՝</strong><br>Հեռախոս (Ռուսաստան)՝ +7 (926) 459-31-57<br>Հեռախոս (Հայաստան)՝ +374 94 86 18 16<br>Էլ. փոստ՝ info&#64;dexarmarket.ru<br>Կայք՝ www.dexarmarket.ru</p> <p><strong>Կապի տեղեկատվություն՝</strong><br>Հեռախոս (Ռուսաստան)՝ +7 (926) 459-31-57<br>Հեռախոս (Հայաստան)՝ +374 94 86 18 16<br>Էլ. փոստ՝ info&#64;dexarmarket.ru<br>Կայք՝ www.dexarmarket.ru</p>

View File

@@ -1,4 +1,4 @@
<div class="legal-page"> <div class="legal-page">
<div class="legal-container"> <div class="legal-container">
<h1>О компании ООО «ИНТ ФИН ЛОГИСТИК»</h1> <h1>О компании ООО «ИНТ ФИН ЛОГИСТИК»</h1>
@@ -65,7 +65,7 @@
<p><strong>Директор:</strong> Оганнисян Ашот Рафикович</p> <p><strong>Директор:</strong> Оганнисян Ашот Рафикович</p>
<p><strong>Юридический адрес:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p> <p><strong>Юридический адрес:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
<p><strong>Офис в Армении:</strong><br>0033, Ереван, улица Братьев Орбели, 47</p> <p><strong>Офис в Армении:</strong><br>0033, Ереван, улица Братьев Орбели, 47</p>
<p><strong>Офис в России:</strong><br>121059, Москва, наб. Тараса Шевченко, 3к2</p>
<p><strong>Основные реквизиты:</strong><br>ИНН (РФ): 9909697628<br>ИНН (Армения): 03033502<br>КПП: 770287001<br>ОГРН: 85.110.1408711</p> <p><strong>Основные реквизиты:</strong><br>ИНН (РФ): 9909697628<br>ИНН (Армения): 03033502<br>КПП: 770287001<br>ОГРН: 85.110.1408711</p>
<p><strong>Банковские реквизиты:</strong><br>Банк: АО "Райффайзенбанк"<br>Расчетный счет: 40807810500000002376<br>Корр. счет: 30101810200000000700<br>БИК: 044525700</p> <p><strong>Банковские реквизиты:</strong><br>Банк: АО "Райффайзенбанк"<br>Расчетный счет: 40807810500000002376<br>Корр. счет: 30101810200000000700<br>БИК: 044525700</p>
<p><strong>Контактная информация:</strong><br>Телефон (Россия): +7 (926) 459-31-57<br>Телефон (Армения): +374 94 86 18 16<br>Email: info&#64;dexarmarket.ru<br>Сайт: www.dexarmarket.ru</p> <p><strong>Контактная информация:</strong><br>Телефон (Россия): +7 (926) 459-31-57<br>Телефон (Армения): +374 94 86 18 16<br>Email: info&#64;dexarmarket.ru<br>Сайт: www.dexarmarket.ru</p>

View File

@@ -1,4 +1,4 @@
<div class="legal-page"> <div class="legal-page">
<div class="legal-container"> <div class="legal-container">
<h1>Contacts</h1> <h1>Contacts</h1>
@@ -35,7 +35,7 @@
<section class="legal-section"> <section class="legal-section">
<h2>Office Addresses</h2> <h2>Office Addresses</h2>
<p><strong>Office in Armenia:</strong> 0033, Yerevan, Orbeli Brothers Street, 47</p> <p><strong>Office in Armenia:</strong> 0033, Yerevan, Orbeli Brothers Street, 47</p>
<p><strong>Office in Russia:</strong> 121059, Moscow, Taras Shevchenko Embankment, 3/2</p>
</section> </section>
<section class="legal-section"> <section class="legal-section">

View File

@@ -1,4 +1,4 @@
<div class="legal-page"> <div class="legal-page">
<div class="legal-container"> <div class="legal-container">
<h1>Կապ</h1> <h1>Կապ</h1>
@@ -35,8 +35,7 @@
<section class="legal-section"> <section class="legal-section">
<h2>Գրասենյակների հասցեներ</h2> <h2>Գրասենյակների հասցեներ</h2>
<p><strong>Գրասենյակ Հայաստանում՝</strong> 0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</p> <p><strong>Գրասենյակ Հայաստանում՝</strong> 0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</p>
<p><strong>Գրասենյակ Ռուսաստանում՝</strong> 121059, Մոսկվա, Տարաս Շևչենկոի փողոց, 3կ2</p> </section>
</section>
<section class="legal-section"> <section class="legal-section">
<h2>Տեխնիկական աջակցություն</h2> <h2>Տեխնիկական աջակցություն</h2>

View File

@@ -1,4 +1,4 @@
<div class="legal-page"> <div class="legal-page">
<div class="legal-container"> <div class="legal-container">
<h1>Контакты</h1> <h1>Контакты</h1>
@@ -35,7 +35,7 @@
<section class="legal-section"> <section class="legal-section">
<h2>Адреса офисов</h2> <h2>Адреса офисов</h2>
<p><strong>Офис в Армении:</strong> 0033, Ереван, улица Братьев Орбели, 47</p> <p><strong>Офис в Армении:</strong> 0033, Ереван, улица Братьев Орбели, 47</p>
<p><strong>Офис в России:</strong> 121059, Москва, наб. Тараса Шевченко, 3к2</p>
</section> </section>
<section class="legal-section"> <section class="legal-section">

View File

@@ -1,240 +1,244 @@
<h1>Frequently Asked Questions (FAQ) 📌</h1> <div class="legal-page">
<div class="legal-container">
<section class="legal-section"> <h1>Frequently Asked Questions (FAQ) 📌</h1>
<h2>General Questions</h2>
<section class="legal-section">
<div class="faq-item"> <h2>General Questions</h2>
<h3>What is DexarMarket?</h3>
<p>DexarMarket is an online marketplace where independent sellers list their products and services. Our goal is to provide a convenient and secure shopping environment — we do not manufacture or sell products ourselves.</p> <div class="faq-item">
<h3>What is DexarMarket?</h3>
<p>DexarMarket is an online marketplace where independent sellers list their products and services. Our goal is to provide a convenient and secure shopping environment — we do not manufacture or sell products ourselves.</p>
</div>
<div class="faq-item">
<h3>How do I register on the platform?</h3>
<p>Registration is easy: open our app within Telegram. Your profile will be created automatically as soon as you log in.</p>
</div>
<div class="faq-item">
<h3>Is it safe to buy on the site?</h3>
<p>Absolutely! All transactions are protected by modern encryption technologies (PCI DSS and 3D Secure). Your banking details are securely stored separately from our system.</p>
</div>
</section>
<section class="legal-section">
<h2>Placing an Order</h2>
<div class="faq-item">
<h3>How do I place an order?</h3>
<p>
1⃣ Find the product you like and add it to your cart.<br>
2⃣ Open the cart and click "Place Order".<br>
3⃣ Enter your delivery address and contact information.<br>
4⃣ Choose a convenient payment method.<br>
5⃣ Review and accept the public offer agreement.<br>
6⃣ Complete the payment for your order.
</p>
</div>
<div class="faq-item">
<h3>Can I make changes to my order after purchase?</h3>
<p>If the seller has not yet shipped the product, contact our support — we will help adjust the order. However, once the product has been shipped, changes are not possible.</p>
</div>
<div class="faq-item">
<h3>How do I cancel an order?</h3>
<p>Contact us or the seller directly through order chats. As long as the package has not been shipped, cancellation is possible with a full refund.</p>
</div>
</section>
<section class="legal-section">
<h2>Payment 💳</h2>
<div class="faq-item">
<h3>What payment methods are available?</h3>
<p>We accept:</p>
<ul>
<li>Bank cards: Visa, MasterCard, MIR,</li>
<li>Fast Payment System (FPS),</li>
<li>Electronic wallets: YooMoney, QIWI,</li>
<li>Cash on delivery (if offered by the seller).</li>
</ul>
</div>
<div class="faq-item">
<h3>When will funds be charged from my card?</h3>
<p>The transaction is processed instantly, but the funds are temporarily held until you receive the product. Once you confirm receipt, the funds are released to the seller.</p>
</div>
<div class="faq-item">
<h3>Why might my payment be declined?</h3>
<p>Possible reasons for decline:</p>
<ul>
<li>Insufficient funds in the account,</li>
<li>Card transaction limit exceeded,</li>
<li>Card blocked by the bank,</li>
<li>Technical issues.</li>
</ul>
<p>We recommend contacting your bank for details.</p>
</div>
<div class="faq-item">
<h3>Will I receive a payment receipt?</h3>
<p>Yes, an electronic receipt will be sent to your specified email immediately after a successful payment in accordance with Federal Law No. 54-FZ.</p>
</div>
</section>
<section class="legal-section">
<h2>Delivery 🚚</h2>
<div class="faq-item">
<h3>Which delivery services are used?</h3>
<p><strong>Digital products:</strong> DexarMarket automatically sends digital products (licenses, keys, certificates) to your email or personal account instantly after payment.</p>
<p><strong>Physical products:</strong> The seller works independently with leading delivery services:</p>
<ul>
<li>CDEK,</li>
<li>Russian Post,</li>
<li>Boxberry,</li>
<li>DPD,</li>
<li>Yandex.Delivery.</li>
</ul>
<p>The delivery method for physical products is chosen based on the seller's preferences and destination.</p>
</div>
<div class="faq-item">
<h3>How much does delivery cost?</h3>
<p><strong>Digital products:</strong> Delivery is free — the product arrives to your email instantly.</p>
<p><strong>Physical products:</strong> The price is determined by the seller and depends on the weight, dimensions, chosen method, and delivery region. The final cost is displayed at checkout.</p>
</div>
<div class="faq-item">
<h3>What is the delivery time for my order?</h3>
<p><strong>Digital products:</strong> Instant delivery to email (within a few minutes after payment).</p>
<p><strong>Physical products</strong> (estimated times):</p>
<ul>
<li>CDEK: 27 business days,</li>
<li>Russian Post: 514 business days,</li>
<li>Boxberry: 25 business days,</li>
<li>DPD: 13 business days,</li>
<li>Yandex.Delivery: same day (if available in your city).</li>
</ul>
</div>
<div class="faq-item">
<h3>How do I track my package?</h3>
<p>You will receive a tracking code via email and can view the order status in your personal account. You can track the package on the official website of the chosen courier service.</p>
</div>
<div class="faq-item">
<h3>What should I do if the product arrived damaged? ⛑</h3>
<p>Inspect the product in front of the courier. If defects are found, fill out a refusal of acceptance report and immediately notify the seller and support service.</p>
</div>
</section>
<section class="legal-section">
<h2>Returns and Exchanges ✅</h2>
<div class="faq-item">
<h3>Can I return a product?</h3>
<p>Yes, the law allows you to return a quality product within 7 days of receipt. Defective items are returned under special rules.</p>
</div>
<div class="faq-item">
<h3>Which products cannot be returned?</h3>
<p>Products listed in Russian Government Decree No. 2463 cannot be returned: medications, cosmetics, underwear, activated digital products, and custom orders. See details in the <a [routerLink]="'/return-policy' | langRoute">"Return Policy"</a> section.</p>
</div>
<div class="faq-item">
<h3>How do I get a refund?</h3>
<p>
1. Notify the seller of your intention to return the product.<br>
2. Return the product in its original packaging.<br>
3. The seller will inspect the product and refund the amount using the same payment method (up to 30 days for the refund to process).
</p>
</div>
<div class="faq-item">
<h3>Who pays for return shipping?</h3>
<p>The buyer pays for return shipping of quality products. If a defect is found, the seller covers the costs.</p>
</div>
</section>
<section class="legal-section">
<h2>Warranty 🔧</h2>
<div class="faq-item">
<h3>Is there a warranty on products?</h3>
<p>Most of our products come with an official manufacturer's warranty. The period ranges from 12 to 36 months and is indicated on each product page.</p>
</div>
<div class="faq-item">
<h3>How do I use the warranty?</h3>
<p>Notify the seller of the defect, attaching photos of the damage and the warranty card. The seller will handle repair or replacement of the product.</p>
</div>
<div class="faq-item">
<h3>What is not covered by warranty?</h3>
<p>Mechanical damage, consequences of improper use, unauthorized repair, moisture exposure (if not protected), and natural wear — none of these are covered under warranty.</p>
</div>
</section>
<section class="legal-section">
<h2>Security and Privacy 🔐</h2>
<div class="faq-item">
<h3>How do you protect my personal data?</h3>
<p>We use modern SSL/TLS encryption methods, do not store card details, and comply with Federal Law No. 152-FZ on personal data protection. Details are available in our <a [routerLink]="'/privacy-policy' | langRoute">Privacy Policy</a>.</p>
</div>
<div class="faq-item">
<h3>Who has access to my data?</h3>
<p>Only your seller for order processing and delivery services. Data is not used by third parties for marketing without your permission.</p>
</div>
<div class="faq-item">
<h3>How do I delete my account?</h3>
<p>Send a request to our support service to delete your account. The account and personal information will be removed within one month.</p>
</div>
</section>
<section class="legal-section">
<h2>Information for Sellers 📋</h2>
<div class="faq-item">
<h3>How do I start selling on the platform?</h3>
<p>To join our sellers, contact our support service by email at <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a>.</p>
</div>
<div class="faq-item">
<h3>What is the platform's commission?</h3>
<p>The commission depends on the product type and sales volume. You can find out the exact terms during registration.</p>
</div>
<div class="faq-item">
<h3>When will I receive payment for a sale?</h3>
<p>The seller receives funds after the customer confirms receipt of the product or two weeks after delivery if the customer has not reported any issues.</p>
</div>
</section>
<section class="legal-section">
<h2>Customer Support 💬</h2>
<div class="faq-item">
<h3>How do I contact support?</h3>
<p>
✉️ <strong>Email:</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a><br>
📞 <strong>Phone (Russia):</strong> <a href="tel:+79264593157">+7 (926) 459-31-57</a><br>
📞 <strong>Phone (Armenia):</strong> <a href="tel:+37494861816">+374 94 86 18 16</a><br>
🏢 <strong>Office hours:</strong> 10:0019:00 (MSK)<br>
❄️ <strong>Technical support is available 24/7.</strong>
</p>
</div>
<div class="faq-item">
<h3>How long does it take to get a response?</h3>
<p>During business hours, you will receive a response within two hours. On holidays and weekends, delays of up to 24 hours are possible.</p>
</div>
</section>
<section class="legal-section">
<h2>Need Help?</h2>
<p>If you have any additional questions, please contact us at <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a> — we will promptly resolve any of your questions!</p>
</section>
</div> </div>
</div>
<div class="faq-item">
<h3>How do I register on the platform?</h3>
<p>Registration is easy: open our app within Telegram. Your profile will be created automatically as soon as you log in.</p>
</div>
<div class="faq-item">
<h3>Is it safe to buy on the site?</h3>
<p>Absolutely! All transactions are protected by modern encryption technologies (PCI DSS and 3D Secure). Your banking details are securely stored separately from our system.</p>
</div>
</section>
<section class="legal-section">
<h2>Placing an Order</h2>
<div class="faq-item">
<h3>How do I place an order?</h3>
<p>
1⃣ Find the product you like and add it to your cart.<br>
2⃣ Open the cart and click "Place Order".<br>
3⃣ Enter your delivery address and contact information.<br>
4⃣ Choose a convenient payment method.<br>
5⃣ Review and accept the public offer agreement.<br>
6⃣ Complete the payment for your order.
</p>
</div>
<div class="faq-item">
<h3>Can I make changes to my order after purchase?</h3>
<p>If the seller has not yet shipped the product, contact our support — we will help adjust the order. However, once the product has been shipped, changes are not possible.</p>
</div>
<div class="faq-item">
<h3>How do I cancel an order?</h3>
<p>Contact us or the seller directly through order chats. As long as the package has not been shipped, cancellation is possible with a full refund.</p>
</div>
</section>
<section class="legal-section">
<h2>Payment 💳</h2>
<div class="faq-item">
<h3>What payment methods are available?</h3>
<p>We accept:</p>
<ul>
<li>Bank cards: Visa, MasterCard, MIR,</li>
<li>Fast Payment System (FPS),</li>
<li>Electronic wallets: YooMoney, QIWI,</li>
<li>Cash on delivery (if offered by the seller).</li>
</ul>
</div>
<div class="faq-item">
<h3>When will funds be charged from my card?</h3>
<p>The transaction is processed instantly, but the funds are temporarily held until you receive the product. Once you confirm receipt, the funds are released to the seller.</p>
</div>
<div class="faq-item">
<h3>Why might my payment be declined?</h3>
<p>Possible reasons for decline:</p>
<ul>
<li>Insufficient funds in the account,</li>
<li>Card transaction limit exceeded,</li>
<li>Card blocked by the bank,</li>
<li>Technical issues.</li>
</ul>
<p>We recommend contacting your bank for details.</p>
</div>
<div class="faq-item">
<h3>Will I receive a payment receipt?</h3>
<p>Yes, an electronic receipt will be sent to your specified email immediately after a successful payment in accordance with Federal Law No. 54-FZ.</p>
</div>
</section>
<section class="legal-section">
<h2>Delivery 🚚</h2>
<div class="faq-item">
<h3>Which delivery services are used?</h3>
<p><strong>Digital products:</strong> DexarMarket automatically sends digital products (licenses, keys, certificates) to your email or personal account instantly after payment.</p>
<p><strong>Physical products:</strong> The seller works independently with leading delivery services:</p>
<ul>
<li>CDEK,</li>
<li>Russian Post,</li>
<li>Boxberry,</li>
<li>DPD,</li>
<li>Yandex.Delivery.</li>
</ul>
<p>The delivery method for physical products is chosen based on the seller's preferences and destination.</p>
</div>
<div class="faq-item">
<h3>How much does delivery cost?</h3>
<p><strong>Digital products:</strong> Delivery is free — the product arrives to your email instantly.</p>
<p><strong>Physical products:</strong> The price is determined by the seller and depends on the weight, dimensions, chosen method, and delivery region. The final cost is displayed at checkout.</p>
</div>
<div class="faq-item">
<h3>What is the delivery time for my order?</h3>
<p><strong>Digital products:</strong> Instant delivery to email (within a few minutes after payment).</p>
<p><strong>Physical products</strong> (estimated times):</p>
<ul>
<li>CDEK: 27 business days,</li>
<li>Russian Post: 514 business days,</li>
<li>Boxberry: 25 business days,</li>
<li>DPD: 13 business days,</li>
<li>Yandex.Delivery: same day (if available in your city).</li>
</ul>
</div>
<div class="faq-item">
<h3>How do I track my package?</h3>
<p>You will receive a tracking code via email and can view the order status in your personal account. You can track the package on the official website of the chosen courier service.</p>
</div>
<div class="faq-item">
<h3>What should I do if the product arrived damaged? ⛑</h3>
<p>Inspect the product in front of the courier. If defects are found, fill out a refusal of acceptance report and immediately notify the seller and support service.</p>
</div>
</section>
<section class="legal-section">
<h2>Returns and Exchanges ✅</h2>
<div class="faq-item">
<h3>Can I return a product?</h3>
<p>Yes, the law allows you to return a quality product within 7 days of receipt. Defective items are returned under special rules.</p>
</div>
<div class="faq-item">
<h3>Which products cannot be returned?</h3>
<p>Products listed in Russian Government Decree No. 2463 cannot be returned: medications, cosmetics, underwear, activated digital products, and custom orders. See details in the <a [routerLink]="'/return-policy' | langRoute">"Return Policy"</a> section.</p>
</div>
<div class="faq-item">
<h3>How do I get a refund?</h3>
<p>
1. Notify the seller of your intention to return the product.<br>
2. Return the product in its original packaging.<br>
3. The seller will inspect the product and refund the amount using the same payment method (up to 30 days for the refund to process).
</p>
</div>
<div class="faq-item">
<h3>Who pays for return shipping?</h3>
<p>The buyer pays for return shipping of quality products. If a defect is found, the seller covers the costs.</p>
</div>
</section>
<section class="legal-section">
<h2>Warranty 🔧</h2>
<div class="faq-item">
<h3>Is there a warranty on products?</h3>
<p>Most of our products come with an official manufacturer's warranty. The period ranges from 12 to 36 months and is indicated on each product page.</p>
</div>
<div class="faq-item">
<h3>How do I use the warranty?</h3>
<p>Notify the seller of the defect, attaching photos of the damage and the warranty card. The seller will handle repair or replacement of the product.</p>
</div>
<div class="faq-item">
<h3>What is not covered by warranty?</h3>
<p>Mechanical damage, consequences of improper use, unauthorized repair, moisture exposure (if not protected), and natural wear — none of these are covered under warranty.</p>
</div>
</section>
<section class="legal-section">
<h2>Security and Privacy 🔐</h2>
<div class="faq-item">
<h3>How do you protect my personal data?</h3>
<p>We use modern SSL/TLS encryption methods, do not store card details, and comply with Federal Law No. 152-FZ on personal data protection. Details are available in our <a [routerLink]="'/privacy-policy' | langRoute">Privacy Policy</a>.</p>
</div>
<div class="faq-item">
<h3>Who has access to my data?</h3>
<p>Only your seller for order processing and delivery services. Data is not used by third parties for marketing without your permission.</p>
</div>
<div class="faq-item">
<h3>How do I delete my account?</h3>
<p>Send a request to our support service to delete your account. The account and personal information will be removed within one month.</p>
</div>
</section>
<section class="legal-section">
<h2>Information for Sellers 📋</h2>
<div class="faq-item">
<h3>How do I start selling on the platform?</h3>
<p>To join our sellers, contact our support service by email at <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a>.</p>
</div>
<div class="faq-item">
<h3>What is the platform's commission?</h3>
<p>The commission depends on the product type and sales volume. You can find out the exact terms during registration.</p>
</div>
<div class="faq-item">
<h3>When will I receive payment for a sale?</h3>
<p>The seller receives funds after the customer confirms receipt of the product or two weeks after delivery if the customer has not reported any issues.</p>
</div>
</section>
<section class="legal-section">
<h2>Customer Support 💬</h2>
<div class="faq-item">
<h3>How do I contact support?</h3>
<p>
✉️ <strong>Email:</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a><br>
📞 <strong>Phone (Russia):</strong> <a href="tel:+79264593157">+7 (926) 459-31-57</a><br>
📞 <strong>Phone (Armenia):</strong> <a href="tel:+37494861816">+374 94 86 18 16</a><br>
🏢 <strong>Office hours:</strong> 10:0019:00 (MSK)<br>
❄️ <strong>Technical support is available 24/7.</strong>
</p>
</div>
<div class="faq-item">
<h3>How long does it take to get a response?</h3>
<p>During business hours, you will receive a response within two hours. On holidays and weekends, delays of up to 24 hours are possible.</p>
</div>
</section>
<section class="legal-section">
<h2>Need Help?</h2>
<p>If you have any additional questions, please contact us at <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a> — we will promptly resolve any of your questions!</p>
</section>

View File

@@ -1,240 +1,244 @@
<h1>Հաճախ տրվող հարցեր (FAQ) 📌</h1> <div class="legal-page">
<div class="legal-container">
<section class="legal-section"> <h1>Հաճախ տրվող հարցեր (FAQ) 📌</h1>
<h2>Ընդհանուր հարցեր</h2>
<section class="legal-section">
<div class="faq-item"> <h2>Ընդհանուր հարցեր</h2>
<h3>Ինչ է DexarMarket-ը։</h3>
<p>DexarMarket-ը օնլայն հարթակ է, որտեղ անկախ վաճառողները տեղադրում են իրենց ապրանքներն ու ծառայությունները։ Մեր նպատակն է ապահովել հարմար ու անվտանգ միջավայր գնումների համար — մենք ինքներս ապրանքներ չենք արտադրում ու չենք վաճառում։</p> <div class="faq-item">
<h3>Ինչ է DexarMarket-ը։</h3>
<p>DexarMarket-ը օնլայն հարթակ է, որտեղ անկախ վաճառողները տեղադրում են իրենց ապրանքներն ու ծառայությունները։ Մեր նպատակն է ապահովել հարմար ու անվտանգ միջավայր գնումների համար — մենք ինքներս ապրանքներ չենք արտադրում ու չենք վաճառում։</p>
</div>
<div class="faq-item">
<h3>Ինչպես գրանցվել հարթակում։</h3>
<p>Գրանցվելը հեշտ է՝ բացեք մեր հավելվածը Telegram-ում։ Ձեր պրոֆիլը կստեղծվի ավտոմատ կերպով մուտք գործելուց հետո։</p>
</div>
<div class="faq-item">
<h3>Անվտանգ է գնել կայքում։</h3>
<p>Անպայմանորեն՝ Բոլոր գործարքները պաշտպանված են ժամանակակից գաղտնագրման տեխնոլոգիաներով (PCI DSS և 3D Secure)։ Ձեր բանկային տվյալները պահվում են ապահով կերպով՝ անջատորեն մեր համակարգից։</p>
</div>
</section>
<section class="legal-section">
<h2>Պատվերի ձևակերպում</h2>
<div class="faq-item">
<h3>Ինչպես կատարել գնում։</h3>
<p>
1⃣ Գտեք ձեզհամած ապրանքը և ավելացրեք զամբյուղին։<br>
2⃣ Բացեք զամբյուղը և սեղմեք «Ձևակերպել պատվերը»։<br>
3⃣ Նշեք առաքման հասցեն և կապի տեղեկատվությունը։<br>
4⃣ Ընտրեք հարմար վճարման եղանակ։<br>
5⃣ Ծանոթացեք և հաստատեք հանրային օֆերտան։<br>
6⃣ Ավարտեք վճարումը։
</p>
</div>
<div class="faq-item">
<h3>Կարելի։ է փոփոխություններ կատարել գնումից հետո։</h3>
<p>Եթե վաճառողը դեռ չի ուղարկել ապրանքը, գրեք մեզ աջակցության ծառայություն — մենք կօգնենք ճշտել պատվերը։ Սակայն ապրանքը ուղարկելուց հետո փոփոխություն հնարավոր չէ։</p>
</div>
<div class="faq-item">
<h3>Ինչպես հրաժարվել պատվերից։</h3>
<p>Կապվեք մեզ հետ կամ անմիջապես վաճառողի հետ պատվերների չատերով։ Մինչև ծանրը չի ուղարկվել, հնարավոր է հրաժարվել լիովին գումարի վերադարձով։</p>
</div>
</section>
<section class="legal-section">
<h2>Վճարում 💳</h2>
<div class="faq-item">
<h3>Ինչ վճարման եղանակներ կան։</h3>
<p>Ընդունվում են՝</p>
<ul>
<li>Visa, MasterCard, ՄԻՌ բանկային քարտեր,</li>
<li>Արագ վճարման համակարգ (ՍԲՊ),</li>
<li>Ելեկտրոնային դրամապանակներ՝ YooMoney, QIWI,</li>
<li>Կանխիկ՝ ստանալուց (եթե վաճառողի կողմից նախատեսված է)։</li>
</ul>
</div>
<div class="faq-item">
<h3>Երբ կգրանցվեն գումարը քարտից։</h3>
<p>Գործարքը կատարվում է անմիջապես, սակայն գումարը ժամանակավորապես սառեցվում է մինչև ապրանքը ստանանք։ Ստացումը հաստատելուց հետո գումարը ֆոխանցվում է վաճառողին։</p>
</div>
<div class="faq-item">
<h3>Ինչու կարող է վճարումս մերժվել։</h3>
<p>Մերժման հնարավոր պատճառները՝</p>
<ul>
<li>Հաշվին անբավարար միջոցներ,</li>
<li>Քարտի գործառնությունների սահմանափակման գերազանցում,</li>
<li>Քարտը արգելափակված է բանկի կողմից,</li>
<li>Տեխնիկական խնդիրներ։</li>
</ul>
<p>Խորհուրդ ենք տալիս դիմել բանկ մանրամասների ճշտելման համար։</p>
</div>
<div class="faq-item">
<h3>Կստանամ վճարման անդրութ։</h3>
<p>Այո, էլեկտրոնային անդրագիրը կուգա ձեր նշված էլ. հասցեին հաջողված վճարմանից անմիջապես հետո՝ համաձայն Վ դաշնային օրենքի № 54-ԿԶ-ի պահանջներին։</p>
</div>
</section>
<section class="legal-section">
<h2>Առաքում 🚚</h2>
<div class="faq-item">
<h3>Ինչ առաքման ծառայություններ կան։</h3>
<p><strong>Թվային ապրանքներ՝</strong> DexarMarket հարթակն ավտոմատ կերպով ուղարկում է թվային ապրանքները (լիցենզիաներ, բանալիներ, վկայագրեր) ձեր էլ. հասցեին կամ անձնական հաշվին՝ վճարմանից անմիջապես հետո։</p>
<p><strong>Նյութական ապրանքներ՝</strong> Վաճառողն ինքնուրույն աշխատում է առաքման ծառայությունների հետ՝</p>
<ul>
<li>ՍԴԷԿ,</li>
<li>Ռուսաստանի փոստ,</li>
<li>Boxberry,</li>
<li>DPD,</li>
<li>Yandex.Առաքում։</li>
</ul>
<p>Նյութական ապրանքների առաքման եղանակը ընտրվում է վաճառողի նախապատվություններին և նպատակակետին համապատասխան։</p>
</div>
<div class="faq-item">
<h3>Ինչքան արժե առաքումը։</h3>
<p><strong>Թվային ապրանքներ՝</strong> Առաքումն անվճար է — ապրանքը կհասնի էլ. հասցեին անմիջապես։</p>
<p><strong>Նյութական ապրանքներ՝</strong> Գինը որոշվում է վաճառողի կողմից և կախված է ապրանքի քաշից, չափսերից, առաքման եղանակից և տարածաշրջանից։ Վերջնական արժեքը ցուցադրվում է պատվերի ձևակերպման ժամանակ։</p>
</div>
<div class="faq-item">
<h3>Ինչքան ժամանակ է առաքումը։</h3>
<p><strong>Թվային ապրանքներ՝</strong> Ակնթարծիկ առաքում էլ. հասցեին (վճարումից հետո մի քանի րոպեի ընթացքում)։</p>
<p><strong>Նյութական ապրանքներ</strong> (մոտավոր ժամկետներ)՝</p>
<ul>
<li>ՍԴԷԿ՝ 27 աշխատանքային օր,</li>
<li>Ռուսաստանի փոստ՝ 514 աշխատանքային օր,</li>
<li>Boxberry՝ 25 աշխատանքային օր,</li>
<li>DPD՝ 13 աշխատանքային օր,</li>
<li>Yandex.Առաքում՝ նույն օրը (եթե ձեր քաղաքում հասանելի է)։</li>
</ul>
</div>
<div class="faq-item">
<h3>Ինչպես հետևել ծանրուկը։</h3>
<p>Դուք կստանաք հետևման կոդը ձեր էլ. հասցեին և կկարողանաք տեսնել պատվերի կարգավիճակը անձնական հաշվում։ Հետևել ծանրուկը կարելի է ընտրված սուրհանդակային ծառայության պաշտոնական կայքում։</p>
</div>
<div class="faq-item">
<h3>Ինչ անել, եթե ապրանքը եկել է վնասված։ ⛑</h3>
<p>Ստուգեք ապրանքը սուրհանդակի ներկայությամբ։ Թերություններ հայտնաբերելու դեպքում կազմեք ընդունման մերժման ակտ և անհապաղ տեղեկացրեք վաճառողին և աջակցության ծառայությանը։</p>
</div>
</section>
<section class="legal-section">
<h2>Վերադարձ և փոխանակում ✅</h2>
<div class="faq-item">
<h3>Կարելի։ է վերադարձնել ապրանքը։</h3>
<p>Այո, օրենքը թույլ է տալիս վերադարձնել որակյալ ապրանքը 7 օրվա ընթացքում։ Թերությունով ապրանքները վերադարձվում են հատուկ կանոններով։</p>
</div>
<div class="faq-item">
<h3>Ինչ ապրանքներ հնարավոր չէ վերադարձնել։</h3>
<p>ՌԴ Կառավարության որոշման № 2463-ով նշված ապրանքները հնարավոր չէ վերադարձնել՝ դեղամիջոցներ, կոսմետիկա, ներքնակի սպիտակեղեն, ակտիվացված թվային ապրանքներ և անհատական պատվերներ։ Մանրամասները տեսեք <a [routerLink]="'/return-policy' | langRoute">«Վերադարձի քաղաքականություն»</a> բաժնում։</p>
</div>
<div class="faq-item">
<h3>Ինչպես վերադարձնել գումարը։</h3>
<p>
1. Տեղեկացրեք վաճառողին ապրանքը վերադարձնելու մասին։<br>
2. Վերադարձրեք ապրանքը սկզբնական փաթեթավորման մեջ։<br>
3. Վաճառողը կստուգի ապրանքի վիճակը և կվերադարձնի գումարը նույն վճարման եղանակով (մինչև 30 օր սպասման ժամկետ)։
</p>
</div>
<div class="faq-item">
<h3>Ով կվճարի հետադարձի առաքումը։</h3>
<p>Գնորդը վճարում է որակյալ ապրանքի հետադարձի առաքումը։ Թերություն հայտնաբերելու դեպքում ծախսերը կրում է վաճառողը։</p>
</div>
</section>
<section class="legal-section">
<h2>Երաշխիք 🔧</h2>
<div class="faq-item">
<h3>Կա։ երաշխիք ապրանքների համար։</h3>
<p>Մեր ապրանքների մեծ մասը ունի արտադրողի պաշտոնական երաշխիք։ Պայմանագիրը տատանվում է 12-ից 36 ամիս և նշված է յուրաքանչյուր ապրանքի էջում։</p>
</div>
<div class="faq-item">
<h3>Ինչպես օգտվել երաշխիքով։</h3>
<p>Տեղեկացրեք վաճառողին թերության մասին՝ կցելով թերությունների լուսանկարներ և երաշխիքային տալոն։ Վաճառողը կզբաղվի վերանորոգմամբ կամ ապրանքի փոխարինումով։</p>
</div>
<div class="faq-item">
<h3>Ինչը չի ընդգրկվում երաշխիքով։</h3>
<p>Մեխանիկական վնասվածքներ, ոչ պատշաճ շահագործման հետևանքներ, ինքնակամ վերանորոգում, խոնավի ազդեցություն (եթե պաշտպանություն չկա), բնական մաշվածում — այս ամենը չի ընդգրկվում երաշխիքով։</p>
</div>
</section>
<section class="legal-section">
<h2>Անվտանգություն և գաղտնիություն 🔐</h2>
<div class="faq-item">
<h3>Ինչպես եք պաշտպանում իմ անձնական տվյալները։</h3>
<p>Մենք օգտագործում ենք SSL/TLS գաղտնագրման ժամանակակից մեթոդներ, չենք պահում քարտերի տվյալները և կատարում ենք № 152-ԿԶ դաշնային օրենքի պահանջները։ Մանրամասները — <a [routerLink]="'/privacy-policy' | langRoute">Գաղտնիության քաղաքականություն</a>ում։</p>
</div>
<div class="faq-item">
<h3>Ում է տրամադրվում իմ տվյալները։</h3>
<p>Միայն ձեր վաճառողին՝ պատվերի մշակման և առաքման ծառայություններին։ Տվյալները չեն օգտագործվում երրորդ կողմերի կողմից շուկայական նպատակներով առանց ձեր թույլտվության։</p>
</div>
<div class="faq-item">
<h3>Ինչպես ջնջել հաշվիս։</h3>
<p>Գրեք մեզ աջակցության ծառայություն՝ հաշվի ջնջման հայտով։ Հաշիվը կլիքվիդացվի անձնական տեղեկատվությամբ մեկ ամսվա ընթացքում։</p>
</div>
</section>
<section class="legal-section">
<h2>Տեղեկատվություն վաճառողների համար 📋</h2>
<div class="faq-item">
<h3>Ինչպես սկսել վաճառել հարթակում։</h3>
<p>Մեր վաճառողներին միանալու համար դիմեք աջակցության ծառայությանը՝ էլեկտրոնային փոստով <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a>։</p>
</div>
<div class="faq-item">
<h3>Ինչքան է հարթակի միջնորդավճարը։</h3>
<p>Միջնորդավճարը կախված է ապրանքի տեսակից և վաճառքի ծավալից։ Ճիշտ պայմանները կարող եք իմանալ գրանցվելուց։</p>
</div>
<div class="faq-item">
<h3>Երբ կստանամ վաճառքի գումարը։</h3>
<p>Վաճառողը ստանում է գումարը հաճախորդի կողմից ապրանքի ստացումը հաստատելուց հետո կամ առաքմանից երկու շաբաթ հետո, եթե հաճախորդը խնդիրներ չի նշել։</p>
</div>
</section>
<section class="legal-section">
<h2>Աջակցության ծառայություն 💬</h2>
<div class="faq-item">
<h3>Ինչպես կապվել աջակցության հետ։</h3>
<p>
✉️ <strong>Էլ. հասցե՝</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a><br>
📞 <strong>Հեռախոս (Ռուսաստան)՝</strong> <a href="tel:+79264593157">+7 (926) 459-31-57</a><br>
📞 <strong>Հեռախոս (Հայաստան)՝</strong> <a href="tel:+37494861816">+374 94 86 18 16</a><br>
🏢 <strong>Գրասենյակի աշխատանքային ժամերը՝</strong> 10:00—19:00 (ՄՍԿ)<br>
❄️ <strong>Տեխնիկական աջակցությունը հասանելի է 24/7։</strong>
</p>
</div>
<div class="faq-item">
<h3>Ինչքան սպասել պատասխան։</h3>
<p>Աշխատանքային օրերին պատասխանը գալիս է երկու ժամվա ընթացքում։ Տոներին և հանգստյան օրերին հնարավոր են ուշացումներ մինչև մեկ օր։</p>
</div>
</section>
<section class="legal-section">
<h2>Օգնություն է հարկավոր։</h2>
<p>Եթե լրացուցիչ հարցեր ունեք, դիմեք <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a> — մենք արագորեն կլուծենք ձեր հարցերը։</p>
</section>
</div> </div>
</div>
<div class="faq-item">
<h3>Ինչպես գրանցվել հարթակում։</h3>
<p>Գրանցվելը հեշտ է՝ բացեք մեր հավելվածը Telegram-ում։ Ձեր պրոֆիլը կստեղծվի ավտոմատ կերպով մուտք գործելուց հետո։</p>
</div>
<div class="faq-item">
<h3>Անվտանգ է գնել կայքում։</h3>
<p>Անպայմանորեն՝ Բոլոր գործարքները պաշտպանված են ժամանակակից գաղտնագրման տեխնոլոգիաներով (PCI DSS և 3D Secure)։ Ձեր բանկային տվյալները պահվում են ապահով կերպով՝ անջատորեն մեր համակարգից։</p>
</div>
</section>
<section class="legal-section">
<h2>Պատվերի ձևակերպում</h2>
<div class="faq-item">
<h3>Ինչպես կատարել գնում։</h3>
<p>
1⃣ Գտեք ձեզհամած ապրանքը և ավելացրեք զամբյուղին։<br>
2⃣ Բացեք զամբյուղը և սեղմեք «Ձևակերպել պատվերը»։<br>
3⃣ Նշեք առաքման հասցեն և կապի տեղեկատվությունը։<br>
4⃣ Ընտրեք հարմար վճարման եղանակ։<br>
5⃣ Ծանոթացեք և հաստատեք հանրային օֆերտան։<br>
6⃣ Ավարտեք վճարումը։
</p>
</div>
<div class="faq-item">
<h3>Կարելի։ է փոփոխություններ կատարել գնումից հետո։</h3>
<p>Եթե վաճառողը դեռ չի ուղարկել ապրանքը, գրեք մեզ աջակցության ծառայություն — մենք կօգնենք ճշտել պատվերը։ Սակայն ապրանքը ուղարկելուց հետո փոփոխություն հնարավոր չէ։</p>
</div>
<div class="faq-item">
<h3>Ինչպես հրաժարվել պատվերից։</h3>
<p>Կապվեք մեզ հետ կամ անմիջապես վաճառողի հետ պատվերների չատերով։ Մինչև ծանրը չի ուղարկվել, հնարավոր է հրաժարվել լիովին գումարի վերադարձով։</p>
</div>
</section>
<section class="legal-section">
<h2>Վճարում 💳</h2>
<div class="faq-item">
<h3>Ինչ վճարման եղանակներ կան։</h3>
<p>Ընդունվում են՝</p>
<ul>
<li>Visa, MasterCard, ՄԻՌ բանկային քարտեր,</li>
<li>Արագ վճարման համակարգ (ՍԲՊ),</li>
<li>Ելեկտրոնային դրամապանակներ՝ YooMoney, QIWI,</li>
<li>Կանխիկ՝ ստանալուց (եթե վաճառողի կողմից նախատեսված է)։</li>
</ul>
</div>
<div class="faq-item">
<h3>Երբ կգրանցվեն գումարը քարտից։</h3>
<p>Գործարքը կատարվում է անմիջապես, սակայն գումարը ժամանակավորապես սառեցվում է մինչև ապրանքը ստանանք։ Ստացումը հաստատելուց հետո գումարը ֆոխանցվում է վաճառողին։</p>
</div>
<div class="faq-item">
<h3>Ինչու կարող է վճարումս մերժվել։</h3>
<p>Մերժման հնարավոր պատճառները՝</p>
<ul>
<li>Հաշվին անբավարար միջոցներ,</li>
<li>Քարտի գործառնությունների սահմանափակման գերազանցում,</li>
<li>Քարտը արգելափակված է բանկի կողմից,</li>
<li>Տեխնիկական խնդիրներ։</li>
</ul>
<p>Խորհուրդ ենք տալիս դիմել բանկ մանրամասների ճշտելման համար։</p>
</div>
<div class="faq-item">
<h3>Կստանամ վճարման անդրութ։</h3>
<p>Այո, էլեկտրոնային անդրագիրը կուգա ձեր նշված էլ. հասցեին հաջողված վճարմանից անմիջապես հետո՝ համաձայն Վ դաշնային օրենքի № 54-ԿԶ-ի պահանջներին։</p>
</div>
</section>
<section class="legal-section">
<h2>Առաքում 🚚</h2>
<div class="faq-item">
<h3>Ինչ առաքման ծառայություններ կան։</h3>
<p><strong>Թվային ապրանքներ՝</strong> DexarMarket հարթակն ավտոմատ կերպով ուղարկում է թվային ապրանքները (լիցենզիաներ, բանալիներ, վկայագրեր) ձեր էլ. հասցեին կամ անձնական հաշվին՝ վճարմանից անմիջապես հետո։</p>
<p><strong>Նյութական ապրանքներ՝</strong> Վաճառողն ինքնուրույն աշխատում է առաքման ծառայությունների հետ՝</p>
<ul>
<li>ՍԴԷԿ,</li>
<li>Ռուսաստանի փոստ,</li>
<li>Boxberry,</li>
<li>DPD,</li>
<li>Yandex.Առաքում։</li>
</ul>
<p>Նյութական ապրանքների առաքման եղանակը ընտրվում է վաճառողի նախապատվություններին և նպատակակետին համապատասխան։</p>
</div>
<div class="faq-item">
<h3>Ինչքան արժե առաքումը։</h3>
<p><strong>Թվային ապրանքներ՝</strong> Առաքումն անվճար է — ապրանքը կհասնի էլ. հասցեին անմիջապես։</p>
<p><strong>Նյութական ապրանքներ՝</strong> Գինը որոշվում է վաճառողի կողմից և կախված է ապրանքի քաշից, չափսերից, առաքման եղանակից և տարածաշրջանից։ Վերջնական արժեքը ցուցադրվում է պատվերի ձևակերպման ժամանակ։</p>
</div>
<div class="faq-item">
<h3>Ինչքան ժամանակ է առաքումը։</h3>
<p><strong>Թվային ապրանքներ՝</strong> Ակնթարծիկ առաքում էլ. հասցեին (վճարումից հետո մի քանի րոպեի ընթացքում)։</p>
<p><strong>Նյութական ապրանքներ</strong> (մոտավոր ժամկետներ)՝</p>
<ul>
<li>ՍԴԷԿ՝ 27 աշխատանքային օր,</li>
<li>Ռուսաստանի փոստ՝ 514 աշխատանքային օր,</li>
<li>Boxberry՝ 25 աշխատանքային օր,</li>
<li>DPD՝ 13 աշխատանքային օր,</li>
<li>Yandex.Առաքում՝ նույն օրը (եթե ձեր քաղաքում հասանելի է)։</li>
</ul>
</div>
<div class="faq-item">
<h3>Ինչպես հետևել ծանրուկը։</h3>
<p>Դուք կստանաք հետևման կոդը ձեր էլ. հասցեին և կկարողանաք տեսնել պատվերի կարգավիճակը անձնական հաշվում։ Հետևել ծանրուկը կարելի է ընտրված սուրհանդակային ծառայության պաշտոնական կայքում։</p>
</div>
<div class="faq-item">
<h3>Ինչ անել, եթե ապրանքը եկել է վնասված։ ⛑</h3>
<p>Ստուգեք ապրանքը սուրհանդակի ներկայությամբ։ Թերություններ հայտնաբերելու դեպքում կազմեք ընդունման մերժման ակտ և անհապաղ տեղեկացրեք վաճառողին և աջակցության ծառայությանը։</p>
</div>
</section>
<section class="legal-section">
<h2>Վերադարձ և փոխանակում ✅</h2>
<div class="faq-item">
<h3>Կարելի։ է վերադարձնել ապրանքը։</h3>
<p>Այո, օրենքը թույլ է տալիս վերադարձնել որակյալ ապրանքը 7 օրվա ընթացքում։ Թերությունով ապրանքները վերադարձվում են հատուկ կանոններով։</p>
</div>
<div class="faq-item">
<h3>Ինչ ապրանքներ հնարավոր չէ վերադարձնել։</h3>
<p>ՌԴ Կառավարության որոշման № 2463-ով նշված ապրանքները հնարավոր չէ վերադարձնել՝ դեղամիջոցներ, կոսմետիկա, ներքնակի սպիտակեղեն, ակտիվացված թվային ապրանքներ և անհատական պատվերներ։ Մանրամասները տեսեք <a [routerLink]="'/return-policy' | langRoute">«Վերադարձի քաղաքականություն»</a> բաժնում։</p>
</div>
<div class="faq-item">
<h3>Ինչպես վերադարձնել գումարը։</h3>
<p>
1. Տեղեկացրեք վաճառողին ապրանքը վերադարձնելու մասին։<br>
2. Վերադարձրեք ապրանքը սկզբնական փաթեթավորման մեջ։<br>
3. Վաճառողը կստուգի ապրանքի վիճակը և կվերադարձնի գումարը նույն վճարման եղանակով (մինչև 30 օր սպասման ժամկետ)։
</p>
</div>
<div class="faq-item">
<h3>Ով կվճարի հետադարձի առաքումը։</h3>
<p>Գնորդը վճարում է որակյալ ապրանքի հետադարձի առաքումը։ Թերություն հայտնաբերելու դեպքում ծախսերը կրում է վաճառողը։</p>
</div>
</section>
<section class="legal-section">
<h2>Երաշխիք 🔧</h2>
<div class="faq-item">
<h3>Կա։ երաշխիք ապրանքների համար։</h3>
<p>Մեր ապրանքների մեծ մասը ունի արտադրողի պաշտոնական երաշխիք։ Պայմանագիրը տատանվում է 12-ից 36 ամիս և նշված է յուրաքանչյուր ապրանքի էջում։</p>
</div>
<div class="faq-item">
<h3>Ինչպես օգտվել երաշխիքով։</h3>
<p>Տեղեկացրեք վաճառողին թերության մասին՝ կցելով թերությունների լուսանկարներ և երաշխիքային տալոն։ Վաճառողը կզբաղվի վերանորոգմամբ կամ ապրանքի փոխարինումով։</p>
</div>
<div class="faq-item">
<h3>Ինչը չի ընդգրկվում երաշխիքով։</h3>
<p>Մեխանիկական վնասվածքներ, ոչ պատշաճ շահագործման հետևանքներ, ինքնակամ վերանորոգում, խոնավի ազդեցություն (եթե պաշտպանություն չկա), բնական մաշվածում — այս ամենը չի ընդգրկվում երաշխիքով։</p>
</div>
</section>
<section class="legal-section">
<h2>Անվտանգություն և գաղտնիություն 🔐</h2>
<div class="faq-item">
<h3>Ինչպես եք պաշտպանում իմ անձնական տվյալները։</h3>
<p>Մենք օգտագործում ենք SSL/TLS գաղտնագրման ժամանակակից մեթոդներ, չենք պահում քարտերի տվյալները և կատարում ենք № 152-ԿԶ դաշնային օրենքի պահանջները։ Մանրամասները — <a [routerLink]="'/privacy-policy' | langRoute">Գաղտնիության քաղաքականություն</a>ում։</p>
</div>
<div class="faq-item">
<h3>Ում է տրամադրվում իմ տվյալները։</h3>
<p>Միայն ձեր վաճառողին՝ պատվերի մշակման և առաքման ծառայություններին։ Տվյալները չեն օգտագործվում երրորդ կողմերի կողմից շուկայական նպատակներով առանց ձեր թույլտվության։</p>
</div>
<div class="faq-item">
<h3>Ինչպես ջնջել հաշվիս։</h3>
<p>Գրեք մեզ աջակցության ծառայություն՝ հաշվի ջնջման հայտով։ Հաշիվը կլիքվիդացվի անձնական տեղեկատվությամբ մեկ ամսվա ընթացքում։</p>
</div>
</section>
<section class="legal-section">
<h2>Տեղեկատվություն վաճառողների համար 📋</h2>
<div class="faq-item">
<h3>Ինչպես սկսել վաճառել հարթակում։</h3>
<p>Մեր վաճառողներին միանալու համար դիմեք աջակցության ծառայությանը՝ էլեկտրոնային փոստով <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a>։</p>
</div>
<div class="faq-item">
<h3>Ինչքան է հարթակի միջնորդավճարը։</h3>
<p>Միջնորդավճարը կախված է ապրանքի տեսակից և վաճառքի ծավալից։ Ճիշտ պայմանները կարող եք իմանալ գրանցվելուց։</p>
</div>
<div class="faq-item">
<h3>Երբ կստանամ վաճառքի գումարը։</h3>
<p>Վաճառողը ստանում է գումարը հաճախորդի կողմից ապրանքի ստացումը հաստատելուց հետո կամ առաքմանից երկու շաբաթ հետո, եթե հաճախորդը խնդիրներ չի նշել։</p>
</div>
</section>
<section class="legal-section">
<h2>Աջակցության ծառայություն 💬</h2>
<div class="faq-item">
<h3>Ինչպես կապվել աջակցության հետ։</h3>
<p>
✉️ <strong>Էլ. հասցե՝</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a><br>
📞 <strong>Հեռախոս (Ռուսաստան)՝</strong> <a href="tel:+79264593157">+7 (926) 459-31-57</a><br>
📞 <strong>Հեռախոս (Հայաստան)՝</strong> <a href="tel:+37494861816">+374 94 86 18 16</a><br>
🏢 <strong>Գրասենյակի աշխատանքային ժամերը՝</strong> 10:00—19:00 (ՄՍԿ)<br>
❄️ <strong>Տեխնիկական աջակցությունը հասանելի է 24/7։</strong>
</p>
</div>
<div class="faq-item">
<h3>Ինչքան սպասել պատասխան։</h3>
<p>Աշխատանքային օրերին պատասխանը գալիս է երկու ժամվա ընթացքում։ Տոներին և հանգստյան օրերին հնարավոր են ուշացումներ մինչև մեկ օր։</p>
</div>
</section>
<section class="legal-section">
<h2>Օգնություն է հարկավոր։</h2>
<p>Եթե լրացուցիչ հարցեր ունեք, դիմեք <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a> — մենք արագորեն կլուծենք ձեր հարցերը։</p>
</section>

View File

@@ -1,240 +1,244 @@
<h1>Часто задаваемые вопросы (FAQ) 📌</h1> <div class="legal-page">
<div class="legal-container">
<section class="legal-section"> <h1>Часто задаваемые вопросы (FAQ) 📌</h1>
<h2>Общие вопросы</h2>
<section class="legal-section">
<div class="faq-item"> <h2>Общие вопросы</h2>
<h3>Что такое DexarMarket?</h3>
<p>DexarMarket — это онлайн-площадка, где независимые продавцы размещают свои товары и услуги. Наша задача — обеспечить удобную и безопасную среду для совершения покупок, мы сами не производим и не продаем товары.</p> <div class="faq-item">
<h3>Что такое DexarMarket?</h3>
<p>DexarMarket — это онлайн-площадка, где независимые продавцы размещают свои товары и услуги. Наша задача — обеспечить удобную и безопасную среду для совершения покупок, мы сами не производим и не продаем товары.</p>
</div>
<div class="faq-item">
<h3>Как зарегистрироваться на платформе?</h3>
<p>Зарегистрироваться легко: откройте наше приложение внутри Telegram. Ваш профиль будет создан автоматически сразу же после входа.</p>
</div>
<div class="faq-item">
<h3>Безопасно ли покупать на сайте?</h3>
<p>Абсолютно да! Все транзакции защищены современными технологиями шифрования (PCI DSS и 3D Secure). Ваши банковские данные надежно хранятся отдельно от нашей системы.</p>
</div>
</section>
<section class="legal-section">
<h2>Оформление заказа</h2>
<div class="faq-item">
<h3>Как правильно оформить покупку?</h3>
<p>
1⃣ Найдите понравившийся товар и добавьте его в корзину.<br>
2⃣ Открыв корзину, кликните «Оформить заказ».<br>
3⃣ Укажите адрес доставки и контактную информацию.<br>
4⃣ Выберите удобный способ оплаты.<br>
5⃣ Ознакомьтесь и подтвердите публичную оферту.<br>
6⃣ Завершите оплату вашего заказа.
</p>
</div>
<div class="faq-item">
<h3>Можно ли внести изменения в заказ после завершения покупки?</h3>
<p>Если продавец ещё не отправил товар, напишите нам в поддержку — мы поможем скорректировать заказ. Но после отправки товара изменение невозможно.</p>
</div>
<div class="faq-item">
<h3>Как отказаться от заказа?</h3>
<p>Свяжитесь с нами или непосредственно с продавцом через чаты заказов. Пока посылка не отправлена, отказ возможен с полным возвратом денег.</p>
</div>
</section>
<section class="legal-section">
<h2>Оплата 💳</h2>
<div class="faq-item">
<h3>Какие способы оплаты доступны?</h3>
<p>Принимаются:</p>
<ul>
<li>Банковские карты Visa, MasterCard, МИР,</li>
<li>Система быстрых платежей (СБП),</li>
<li>Электронные кошельки ЮMoney, QIWI,</li>
<li>Наличные при получении (если предусмотрено продавцом).</li>
</ul>
</div>
<div class="faq-item">
<h3>Когда спишутся средства с моей карты?</h3>
<p>Операция проходит мгновенно, но деньги временно замораживаются до момента получения вами товара. Как только вы подтверждаете получение, средства отправляются продавцу.</p>
</div>
<div class="faq-item">
<h3>Почему мой платёж мог быть отклонён?</h3>
<p>Возможные причины отказа:</p>
<ul>
<li>Недостаточно средств на счёте,</li>
<li>Превышение лимита операций по карте,</li>
<li>Карта заблокирована банком,</li>
<li>Технические сбои.</li>
</ul>
<p>Рекомендуем обратиться в банк для выяснения деталей.</p>
</div>
<div class="faq-item">
<h3>Получу ли я чек об оплате?</h3>
<p>Да, электронная квитанция придёт на указанный вами e-mail сразу после успешного платежа согласно закону № 54-ФЗ.</p>
</div>
</section>
<section class="legal-section">
<h2>Доставка 🚚</h2>
<div class="faq-item">
<h3>Через какие службы осуществляется доставка?</h3>
<p><strong>Цифровые товары:</strong> Платформа DexarMarket автоматически отправляет цифровые товары (лицензии, ключи, сертификаты) на вашу электронную почту или в личный кабинет мгновенно после оплаты.</p>
<p><strong>Материальные товары:</strong> Продавец самостоятельно работает с ведущими службами доставки:</p>
<ul>
<li>СДЭК,</li>
<li>Почта России,</li>
<li>Boxberry,</li>
<li>DPD,</li>
<li>Яндекс.Доставка.</li>
</ul>
<p>Способ доставки материальных товаров выбирается исходя из предпочтений продавца и места назначения.</p>
</div>
<div class="faq-item">
<h3>Сколько стоит доставка моего заказа?</h3>
<p><strong>Цифровые товары:</strong> Доставка бесплатная — товар придёт на email мгновенно.</p>
<p><strong>Материальные товары:</strong> Цена определяется продавцом и зависит от веса, габаритов товара, выбранного способа и региона доставки. Окончательная стоимость видна при оформлении заказа.</p>
</div>
<div class="faq-item">
<h3>Какой срок доставки моего заказа?</h3>
<p><strong>Цифровые товары:</strong> Мгновенная доставка на email (в течение нескольких минут после оплаты).</p>
<p><strong>Материальные товары</strong> (примерные сроки):</p>
<ul>
<li>СДЭК: 27 рабочих дней,</li>
<li>Почта России: 514 рабочих дней,</li>
<li>Boxberry: 25 рабочих дней,</li>
<li>DPD: 13 рабочих дня,</li>
<li>Яндекс.Доставка: в тот же день (при наличии возможности в вашем городе).</li>
</ul>
</div>
<div class="faq-item">
<h3>Как отслеживать мою посылку?</h3>
<p>Вы получите трек-код на свою электронную почту и сможете увидеть статус заказа в личном кабинете. Отслеживать посылку можно на официальном сайте выбранной курьерской службы.</p>
</div>
<div class="faq-item">
<h3>Что делать, если товар пришёл повреждённым? ⛑</h3>
<p>Осмотрите товар прямо при курьере. Если обнаружились дефекты — оформляйте акт отказа от приёмки и незамедлительно сообщайте продавцу и службе поддержки.</p>
</div>
</section>
<section class="legal-section">
<h2>Возврат и обмен ✅</h2>
<div class="faq-item">
<h3>Можно ли вернуть товар?</h3>
<p>Да, закон позволяет вернуть качественный товар в течение 7 дней после получения. Бракованные изделия возвращаются по особым правилам.</p>
</div>
<div class="faq-item">
<h3>Какие товары вернуть нельзя?</h3>
<p>Нельзя вернуть товары, перечисленные в Постановлении Правительства РФ №2463: медикаменты, косметику, бельё, активированные цифровые продукты и индивидуальные заказы. Подробности смотрите в разделе <a [routerLink]="'/return-policy' | langRoute">«Политика возврата»</a>.</p>
</div>
<div class="faq-item">
<h3>Как вернуть деньги?</h3>
<p>
1. Сообщите продавцу о намерении вернуть товар.<br>
2. Верните товар в оригинальной упаковке.<br>
3. Продавец проверит состояние товара и вернёт средства таким же способом, каким была произведена покупка (до 30 дней ожидания возврата).
</p>
</div>
<div class="faq-item">
<h3>Кто оплатит обратный путь?</h3>
<p>Покупатель оплачивает обратную доставку качественного товара. Если обнаружен брак — расходы несёт продавец.</p>
</div>
</section>
<section class="legal-section">
<h2>Гарантия 🔧</h2>
<div class="faq-item">
<h3>Есть ли гарантия на товары?</h3>
<p>Большинство наших товаров имеют официальную гарантию производителя. Срок варьируется от 12 до 36 месяцев и указывается в карточке каждого продукта.</p>
</div>
<div class="faq-item">
<h3>Как воспользоваться гарантией?</h3>
<p>Сообщите продавцу о неисправности, приложив фотографии дефектов и гарантийный талон. Продавец займётся ремонтом или заменой товара.</p>
</div>
<div class="faq-item">
<h3>Что не покрыто гарантией?</h3>
<p>Механические повреждения, последствия неправильной эксплуатации, самостоятельный ремонт, влияние влаги (если нет защиты), естественный износ — всё это не является страховым случаем.</p>
</div>
</section>
<section class="legal-section">
<h2>Безопасность и конфиденциальность 🔐</h2>
<div class="faq-item">
<h3>Как вы защищаете мои личные данные?</h3>
<p>Используем современные методы шифрования SSL/TLS, не храним реквизиты карт, выполняем требования закона №152-ФЗ о защите персональной информации. Подробности — в <a [routerLink]="'/privacy-policy' | langRoute">Политике конфиденциальности</a>.</p>
</div>
<div class="faq-item">
<h3>Кому предоставляются мои данные?</h3>
<p>Только вашему продавцу для обработки заказа и службам доставки. Данные не используются третьими лицами для маркетинга без вашего разрешения.</p>
</div>
<div class="faq-item">
<h3>Как удалить свой аккаунт?</h3>
<p>Напишите нам в службу поддержки запрос на удаление учётной записи. Аккаунт будет ликвидирован вместе с личной информацией в течение месяца.</p>
</div>
</section>
<section class="legal-section">
<h2>Информация для продавцов 📋</h2>
<div class="faq-item">
<h3>Как начать продавать на площадке?</h3>
<p>Чтобы присоединиться к нашим продавцам, обратитесь в службу поддержки по электронной почте <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a>.</p>
</div>
<div class="faq-item">
<h3>Какова комиссия площадки?</h3>
<p>Комиссия зависит от типа товара и объёма продаж. Узнать точные условия можно при регистрации.</p>
</div>
<div class="faq-item">
<h3>Когда поступят деньги за продажу?</h3>
<p>Продавец получает средства после подтверждения клиентом получения товара либо спустя две недели с момента доставки, если клиент не указал проблем с покупкой.</p>
</div>
</section>
<section class="legal-section">
<h2>Служба поддержки 💬</h2>
<div class="faq-item">
<h3>Как связаться с поддержкой?</h3>
<p>
✉️ <strong>Email:</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a><br>
📞 <strong>Телефон (Россия):</strong> <a href="tel:+79264593157">+7 (926) 459-31-57</a><br>
📞 <strong>Телефон (Армения):</strong> <a href="tel:+37494861816">+374 94 86 18 16</a><br>
🏢 <strong>Время работы офиса:</strong> 10:00—19:00 (МСК)<br>
❄️ <strong>Техподдержка доступна круглосуточно.</strong>
</p>
</div>
<div class="faq-item">
<h3>Сколько ждать ответа?</h3>
<p>Во время рабочего дня ответ поступает в течение двух часов. В праздники и выходные возможны задержки до суток.</p>
</div>
</section>
<section class="legal-section">
<h2>Нужна помощь?</h2>
<p>Если возникнут дополнительные вопросы, обращайтесь на <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a> — мы оперативно решим любые ваши вопросы!</p>
</section>
</div> </div>
</div>
<div class="faq-item">
<h3>Как зарегистрироваться на платформе?</h3>
<p>Зарегистрироваться легко: откройте наше приложение внутри Telegram. Ваш профиль будет создан автоматически сразу же после входа.</p>
</div>
<div class="faq-item">
<h3>Безопасно ли покупать на сайте?</h3>
<p>Абсолютно да! Все транзакции защищены современными технологиями шифрования (PCI DSS и 3D Secure). Ваши банковские данные надежно хранятся отдельно от нашей системы.</p>
</div>
</section>
<section class="legal-section">
<h2>Оформление заказа</h2>
<div class="faq-item">
<h3>Как правильно оформить покупку?</h3>
<p>
1⃣ Найдите понравившийся товар и добавьте его в корзину.<br>
2⃣ Открыв корзину, кликните «Оформить заказ».<br>
3⃣ Укажите адрес доставки и контактную информацию.<br>
4⃣ Выберите удобный способ оплаты.<br>
5⃣ Ознакомьтесь и подтвердите публичную оферту.<br>
6⃣ Завершите оплату вашего заказа.
</p>
</div>
<div class="faq-item">
<h3>Можно ли внести изменения в заказ после завершения покупки?</h3>
<p>Если продавец ещё не отправил товар, напишите нам в поддержку — мы поможем скорректировать заказ. Но после отправки товара изменение невозможно.</p>
</div>
<div class="faq-item">
<h3>Как отказаться от заказа?</h3>
<p>Свяжитесь с нами или непосредственно с продавцом через чаты заказов. Пока посылка не отправлена, отказ возможен с полным возвратом денег.</p>
</div>
</section>
<section class="legal-section">
<h2>Оплата 💳</h2>
<div class="faq-item">
<h3>Какие способы оплаты доступны?</h3>
<p>Принимаются:</p>
<ul>
<li>Банковские карты Visa, MasterCard, МИР,</li>
<li>Система быстрых платежей (СБП),</li>
<li>Электронные кошельки ЮMoney, QIWI,</li>
<li>Наличные при получении (если предусмотрено продавцом).</li>
</ul>
</div>
<div class="faq-item">
<h3>Когда спишутся средства с моей карты?</h3>
<p>Операция проходит мгновенно, но деньги временно замораживаются до момента получения вами товара. Как только вы подтверждаете получение, средства отправляются продавцу.</p>
</div>
<div class="faq-item">
<h3>Почему мой платёж мог быть отклонён?</h3>
<p>Возможные причины отказа:</p>
<ul>
<li>Недостаточно средств на счёте,</li>
<li>Превышение лимита операций по карте,</li>
<li>Карта заблокирована банком,</li>
<li>Технические сбои.</li>
</ul>
<p>Рекомендуем обратиться в банк для выяснения деталей.</p>
</div>
<div class="faq-item">
<h3>Получу ли я чек об оплате?</h3>
<p>Да, электронная квитанция придёт на указанный вами e-mail сразу после успешного платежа согласно закону № 54-ФЗ.</p>
</div>
</section>
<section class="legal-section">
<h2>Доставка 🚚</h2>
<div class="faq-item">
<h3>Через какие службы осуществляется доставка?</h3>
<p><strong>Цифровые товары:</strong> Платформа DexarMarket автоматически отправляет цифровые товары (лицензии, ключи, сертификаты) на вашу электронную почту или в личный кабинет мгновенно после оплаты.</p>
<p><strong>Материальные товары:</strong> Продавец самостоятельно работает с ведущими службами доставки:</p>
<ul>
<li>СДЭК,</li>
<li>Почта России,</li>
<li>Boxberry,</li>
<li>DPD,</li>
<li>Яндекс.Доставка.</li>
</ul>
<p>Способ доставки материальных товаров выбирается исходя из предпочтений продавца и места назначения.</p>
</div>
<div class="faq-item">
<h3>Сколько стоит доставка моего заказа?</h3>
<p><strong>Цифровые товары:</strong> Доставка бесплатная — товар придёт на email мгновенно.</p>
<p><strong>Материальные товары:</strong> Цена определяется продавцом и зависит от веса, габаритов товара, выбранного способа и региона доставки. Окончательная стоимость видна при оформлении заказа.</p>
</div>
<div class="faq-item">
<h3>Какой срок доставки моего заказа?</h3>
<p><strong>Цифровые товары:</strong> Мгновенная доставка на email (в течение нескольких минут после оплаты).</p>
<p><strong>Материальные товары</strong> (примерные сроки):</p>
<ul>
<li>СДЭК: 27 рабочих дней,</li>
<li>Почта России: 514 рабочих дней,</li>
<li>Boxberry: 25 рабочих дней,</li>
<li>DPD: 13 рабочих дня,</li>
<li>Яндекс.Доставка: в тот же день (при наличии возможности в вашем городе).</li>
</ul>
</div>
<div class="faq-item">
<h3>Как отслеживать мою посылку?</h3>
<p>Вы получите трек-код на свою электронную почту и сможете увидеть статус заказа в личном кабинете. Отслеживать посылку можно на официальном сайте выбранной курьерской службы.</p>
</div>
<div class="faq-item">
<h3>Что делать, если товар пришёл повреждённым? ⛑</h3>
<p>Осмотрите товар прямо при курьере. Если обнаружились дефекты — оформляйте акт отказа от приёмки и незамедлительно сообщайте продавцу и службе поддержки.</p>
</div>
</section>
<section class="legal-section">
<h2>Возврат и обмен ✅</h2>
<div class="faq-item">
<h3>Можно ли вернуть товар?</h3>
<p>Да, закон позволяет вернуть качественный товар в течение 7 дней после получения. Бракованные изделия возвращаются по особым правилам.</p>
</div>
<div class="faq-item">
<h3>Какие товары вернуть нельзя?</h3>
<p>Нельзя вернуть товары, перечисленные в Постановлении Правительства РФ №2463: медикаменты, косметику, бельё, активированные цифровые продукты и индивидуальные заказы. Подробности смотрите в разделе <a [routerLink]="'/return-policy' | langRoute">«Политика возврата»</a>.</p>
</div>
<div class="faq-item">
<h3>Как вернуть деньги?</h3>
<p>
1. Сообщите продавцу о намерении вернуть товар.<br>
2. Верните товар в оригинальной упаковке.<br>
3. Продавец проверит состояние товара и вернёт средства таким же способом, каким была произведена покупка (до 30 дней ожидания возврата).
</p>
</div>
<div class="faq-item">
<h3>Кто оплатит обратный путь?</h3>
<p>Покупатель оплачивает обратную доставку качественного товара. Если обнаружен брак — расходы несёт продавец.</p>
</div>
</section>
<section class="legal-section">
<h2>Гарантия 🔧</h2>
<div class="faq-item">
<h3>Есть ли гарантия на товары?</h3>
<p>Большинство наших товаров имеют официальную гарантию производителя. Срок варьируется от 12 до 36 месяцев и указывается в карточке каждого продукта.</p>
</div>
<div class="faq-item">
<h3>Как воспользоваться гарантией?</h3>
<p>Сообщите продавцу о неисправности, приложив фотографии дефектов и гарантийный талон. Продавец займётся ремонтом или заменой товара.</p>
</div>
<div class="faq-item">
<h3>Что не покрыто гарантией?</h3>
<p>Механические повреждения, последствия неправильной эксплуатации, самостоятельный ремонт, влияние влаги (если нет защиты), естественный износ — всё это не является страховым случаем.</p>
</div>
</section>
<section class="legal-section">
<h2>Безопасность и конфиденциальность 🔐</h2>
<div class="faq-item">
<h3>Как вы защищаете мои личные данные?</h3>
<p>Используем современные методы шифрования SSL/TLS, не храним реквизиты карт, выполняем требования закона №152-ФЗ о защите персональной информации. Подробности — в <a [routerLink]="'/privacy-policy' | langRoute">Политике конфиденциальности</a>.</p>
</div>
<div class="faq-item">
<h3>Кому предоставляются мои данные?</h3>
<p>Только вашему продавцу для обработки заказа и службам доставки. Данные не используются третьими лицами для маркетинга без вашего разрешения.</p>
</div>
<div class="faq-item">
<h3>Как удалить свой аккаунт?</h3>
<p>Напишите нам в службу поддержки запрос на удаление учётной записи. Аккаунт будет ликвидирован вместе с личной информацией в течение месяца.</p>
</div>
</section>
<section class="legal-section">
<h2>Информация для продавцов 📋</h2>
<div class="faq-item">
<h3>Как начать продавать на площадке?</h3>
<p>Чтобы присоединиться к нашим продавцам, обратитесь в службу поддержки по электронной почте <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a>.</p>
</div>
<div class="faq-item">
<h3>Какова комиссия площадки?</h3>
<p>Комиссия зависит от типа товара и объёма продаж. Узнать точные условия можно при регистрации.</p>
</div>
<div class="faq-item">
<h3>Когда поступят деньги за продажу?</h3>
<p>Продавец получает средства после подтверждения клиентом получения товара либо спустя две недели с момента доставки, если клиент не указал проблем с покупкой.</p>
</div>
</section>
<section class="legal-section">
<h2>Служба поддержки 💬</h2>
<div class="faq-item">
<h3>Как связаться с поддержкой?</h3>
<p>
✉️ <strong>Email:</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a><br>
📞 <strong>Телефон (Россия):</strong> <a href="tel:+79264593157">+7 (926) 459-31-57</a><br>
📞 <strong>Телефон (Армения):</strong> <a href="tel:+37494861816">+374 94 86 18 16</a><br>
🏢 <strong>Время работы офиса:</strong> 10:00—19:00 (МСК)<br>
❄️ <strong>Техподдержка доступна круглосуточно.</strong>
</p>
</div>
<div class="faq-item">
<h3>Сколько ждать ответа?</h3>
<p>Во время рабочего дня ответ поступает в течение двух часов. В праздники и выходные возможны задержки до суток.</p>
</div>
</section>
<section class="legal-section">
<h2>Нужна помощь?</h2>
<p>Если возникнут дополнительные вопросы, обращайтесь на <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a> — мы оперативно решим любые ваши вопросы!</p>
</section>

View File

@@ -55,7 +55,23 @@
</div> </div>
<div class="novo-info"> <div class="novo-info">
<h1 class="novo-title">{{ item()!.name }}</h1> <h1 class="novo-title">{{ getItemName() }}</h1>
@if (item()!.badges && item()!.badges!.length > 0) {
<div class="novo-badges">
@for (badge of item()!.badges!; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
@if (item()!.tags && item()!.tags!.length > 0) {
<div class="novo-tags">
@for (tag of item()!.tags!; track tag) {
<span class="item-tag">#{{ tag }}</span>
}
</div>
}
<div class="novo-rating"> <div class="novo-rating">
<span class="stars">{{ getRatingStars(item()!.rating) }}</span> <span class="stars">{{ getRatingStars(item()!.rating) }}</span>
@@ -66,23 +82,68 @@
<div class="novo-price-block"> <div class="novo-price-block">
@if (item()!.discount > 0) { @if (item()!.discount > 0) {
<div class="price-row"> <div class="price-row">
<span class="old-price">{{ item()!.price }} {{ item()!.currency }}</span> <span class="old-price">{{ effectivePrice() }} {{ effectiveCurrency() }}</span>
<span class="discount-badge">-{{ item()!.discount }}%</span> <span class="discount-badge">-{{ item()!.discount }}%</span>
</div> </div>
<div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ item()!.currency }}</div> <div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ effectiveCurrency() }}</div>
} @else { } @else {
<div class="current-price">{{ item()!.price }} {{ item()!.currency }}</div> <div class="current-price">{{ effectivePrice() }} {{ effectiveCurrency() }}</div>
} }
</div> </div>
<div class="novo-stock"> <div class="novo-stock">
<span class="stock-label">{{ 'itemDetail.stock' | translate }}</span> <span class="stock-label">{{ 'itemDetail.stock' | translate }}</span>
<div class="stock-indicator" [class.high]="item()!.remainings === 'high'" [class.medium]="item()!.remainings === 'medium'" [class.low]="item()!.remainings === 'low'"> <div class="stock-indicator" [class]="getStockClass()">
<span class="dot"></span> <span class="dot"></span>
{{ item()!.remainings === 'high' ? ('itemDetail.inStock' | translate) : item()!.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lowStock' | translate) }} {{ getStockLabel() }}
</div> </div>
@if (effectiveRemaining() != null) {
<span class="stock-qty">({{ effectiveRemaining() }} шт.)</span>
}
</div> </div>
@if (availableColours().length || availableSizes().length || item()!.colour || (item()!.size && item()!.size!.toLowerCase() !== 'default')) {
<div class="novo-variants">
@if (availableColours().length) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
@for (c of availableColours(); track c) {
<span class="colour-swatch" [class.active]="selectedColour() === c" [style.background-color]="c" [title]="c" (click)="selectColour(c)"></span>
}
</div>
} @else if (item()!.colour) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
<span class="colour-swatch active" [style.background-color]="item()!.colour" [title]="item()!.colour"></span>
</div>
}
@if (availableSizes().length) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
@for (s of availableSizes(); track s) {
<span class="variant-chip size-chip" [class.active]="selectedSize() === s" (click)="selectSize(s)">{{ s }}</span>
}
</div>
} @else if (item()!.size && item()!.size!.toLowerCase() !== 'default') {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
<span class="variant-chip size-chip active">{{ item()!.size }}</span>
</div>
}
</div>
}
@if (item()!.attributes && item()!.attributes!.length > 0) {
<div class="novo-attributes">
@for (attr of item()!.attributes!; track attr.key) {
<div class="attribute-row">
<span class="attribute-key">{{ attr.key }}</span>
<span class="attribute-value">{{ attr.value }}</span>
</div>
}
</div>
}
<button class="novo-add-cart" (click)="addToCart()"> <button class="novo-add-cart" (click)="addToCart()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="9" cy="21" r="1"></circle> <circle cx="9" cy="21" r="1"></circle>
@@ -93,8 +154,26 @@
</button> </button>
<div class="novo-description"> <div class="novo-description">
<h3>{{ 'itemDetail.description' | translate }}</h3> @if (getSimpleDescription()) {
<div [innerHTML]="getSafeHtml(item()!.description)"></div> <p class="novo-simple-desc">{{ getSimpleDescription() }}</p>
}
@if (hasDescriptionFields()) {
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
<table class="novo-specs-table">
<tbody>
@for (field of getTranslatedDescriptionFields(); track field.key) {
<tr>
<td class="spec-key">{{ field.key }}</td>
<td class="spec-value">{{ field.value }}</td>
</tr>
}
</tbody>
</table>
} @else {
<h3>{{ 'itemDetail.description' | translate }}</h3>
<div [innerHTML]="getSafeHtml(item()!.description)"></div>
}
</div> </div>
</div> </div>
</div> </div>
@@ -249,7 +328,23 @@
<!-- Item Info --> <!-- Item Info -->
<div class="dx-info"> <div class="dx-info">
<h1 class="dx-title">{{ item()!.name }}</h1> <h1 class="dx-title">{{ getItemName() }}</h1>
@if (item()!.badges && item()!.badges!.length > 0) {
<div class="dx-badges">
@for (badge of item()!.badges!; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
@if (item()!.tags && item()!.tags!.length > 0) {
<div class="dx-tags">
@for (tag of item()!.tags!; track tag) {
<span class="item-tag">#{{ tag }}</span>
}
</div>
}
<div class="dx-rating"> <div class="dx-rating">
<div class="dx-stars"> <div class="dx-stars">
@@ -266,26 +361,68 @@
<div class="dx-price-block"> <div class="dx-price-block">
@if (item()!.discount > 0) { @if (item()!.discount > 0) {
<div class="dx-price-row"> <div class="dx-price-row">
<span class="dx-old-price">{{ item()!.price }} {{ item()!.currency }}</span> <span class="dx-old-price">{{ effectivePrice() }} {{ effectiveCurrency() }}</span>
<span class="dx-discount-tag">-{{ item()!.discount }}%</span> <span class="dx-discount-tag">-{{ item()!.discount }}%</span>
</div> </div>
} }
<div class="dx-current-price"> <div class="dx-current-price">
{{ item()!.discount > 0 ? (getDiscountedPrice() | number:'1.2-2') : item()!.price }} {{ item()!.currency }} {{ item()!.discount > 0 ? (getDiscountedPrice() | number:'1.2-2') : effectivePrice() }} {{ effectiveCurrency() }}
</div> </div>
</div> </div>
<div class="dx-stock"> <div class="dx-stock">
<span class="dx-stock-label">{{ 'itemDetail.stock' | translate }}</span> <span class="dx-stock-label">{{ 'itemDetail.stock' | translate }}</span>
<span class="dx-stock-status" <span class="dx-stock-status" [class]="getStockClass()">
[class.high]="item()!.remainings === 'high'"
[class.medium]="item()!.remainings === 'medium'"
[class.low]="item()!.remainings === 'low'">
<span class="dx-stock-dot"></span> <span class="dx-stock-dot"></span>
{{ item()!.remainings === 'high' ? ('itemDetail.inStock' | translate) : item()!.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lastItems' | translate) }} {{ getStockLabel() }}
</span> </span>
@if (effectiveRemaining() != null) {
<span class="dx-stock-qty">({{ effectiveRemaining() }} шт.)</span>
}
</div> </div>
@if (availableColours().length || availableSizes().length || item()!.colour || (item()!.size && item()!.size!.toLowerCase() !== 'default')) {
<div class="dx-variants">
@if (availableColours().length) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
@for (c of availableColours(); track c) {
<span class="colour-swatch" [class.active]="selectedColour() === c" [style.background-color]="c" [title]="c" (click)="selectColour(c)"></span>
}
</div>
} @else if (item()!.colour) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
<span class="colour-swatch active" [style.background-color]="item()!.colour" [title]="item()!.colour"></span>
</div>
}
@if (availableSizes().length) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
@for (s of availableSizes(); track s) {
<span class="variant-chip size-chip" [class.active]="selectedSize() === s" (click)="selectSize(s)">{{ s }}</span>
}
</div>
} @else if (item()!.size && item()!.size!.toLowerCase() !== 'default') {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
<span class="variant-chip size-chip active">{{ item()!.size }}</span>
</div>
}
</div>
}
@if (item()!.attributes && item()!.attributes!.length > 0) {
<div class="dx-attributes">
@for (attr of item()!.attributes!; track attr.key) {
<div class="attribute-row">
<span class="attribute-key">{{ attr.key }}</span>
<span class="attribute-value">{{ attr.value }}</span>
</div>
}
</div>
}
<button class="dx-add-cart" (click)="addToCart()"> <button class="dx-add-cart" (click)="addToCart()">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="9" cy="21" r="1"></circle> <circle cx="9" cy="21" r="1"></circle>
@@ -296,8 +433,27 @@
</button> </button>
<div class="dx-description"> <div class="dx-description">
<h2>{{ 'itemDetail.description' | translate }}</h2> <!-- @if (getSimpleDescription()) { -->
<div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div> @if (false) {
<p class="dx-simple-desc">{{ getSimpleDescription() }}</p>
}
@if (hasDescriptionFields()) {
<h2>{{ 'itemDetail.specifications' | translate }}</h2>
<table class="dx-specs-table">
<tbody>
@for (field of getTranslatedDescriptionFields(); track field.key) {
<tr>
<td class="spec-key">{{ field.key }}</td>
<td class="spec-value">{{ field.value }}</td>
</tr>
}
</tbody>
</table>
} @else {
<h2>{{ 'itemDetail.description' | translate }}</h2>
<div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div>
}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,6 @@
// ========== DEXAR ITEM DETAIL - Redesigned 2026 ========== @use 'sass:color';
// ========== DEXAR ITEM DETAIL - Redesigned 2026 ==========
$dx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; $dx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
$dx-dark: #1e3c38; $dx-dark: #1e3c38;
$dx-primary: #497671; $dx-primary: #497671;
@@ -50,7 +52,7 @@ $dx-card-bg: #f5f3f9;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
background: darken($dx-primary, 8%); background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-1px); transform: translateY(-1px);
} }
} }
@@ -281,7 +283,7 @@ $dx-card-bg: #f5f3f9;
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15); box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
&:hover { &:hover {
background: darken($dx-primary, 8%); background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(73, 118, 113, 0.3); box-shadow: 0 6px 16px rgba(73, 118, 113, 0.3);
} }
@@ -291,6 +293,96 @@ $dx-card-bg: #f5f3f9;
} }
} }
// Variant chips (colour/size) — shared between dexar and novo
.dx-variants, .novo-variants {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
.variant-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.variant-label {
font-size: 0.9rem;
color: #6b7280;
font-weight: 500;
}
.variant-chip {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
border: 1.5px solid $dx-border;
background: rgba(73, 118, 113, 0.06);
color: $dx-primary;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
&:hover {
background: rgba(73, 118, 113, 0.12);
}
&.active {
border-color: $dx-primary;
background: rgba(73, 118, 113, 0.18);
box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.25);
}
}
.colour-swatch {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid $dx-border;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
flex-shrink: 0;
&:hover {
border-color: $dx-primary;
}
&.active {
border-color: $dx-primary;
box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.25);
}
}
}
.dx-attributes, .novo-attributes {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin-bottom: 14px;
padding: 10px 14px;
background: #f8fafa;
border-radius: 10px;
.attribute-row {
display: flex;
gap: 6px;
font-size: 0.85rem;
}
.attribute-key {
color: #6b7280;
&::after { content: ':'; }
}
.attribute-value {
font-weight: 600;
color: #1a1a1a;
}
}
.dx-description { .dx-description {
padding-top: 8px; padding-top: 8px;
border-top: 1px solid $dx-border; border-top: 1px solid $dx-border;
@@ -434,7 +526,7 @@ $dx-card-bg: #f5f3f9;
justify-content: center; justify-content: center;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: darken($dx-primary, 8%); background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -642,22 +734,70 @@ $dx-card-bg: #f5f3f9;
} }
} }
// Responsive // ========== DEXAR RESPONSIVE ==========
// Large desktop — constrain gallery height
@media (min-width: 1201px) {
.dx-main-photo {
max-height: 560px;
}
}
// Tablet landscape / small desktop
@media (max-width: 1200px) {
.dx-item-content {
gap: 32px;
}
.dx-title {
font-size: 1.5rem;
}
}
// Tablet portrait
@media (max-width: 992px) { @media (max-width: 992px) {
.dx-item-content { .dx-item-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 32px; gap: 32px;
} }
.dx-gallery {
max-width: 600px;
margin: 0 auto;
}
.dx-main-photo {
max-height: 480px;
aspect-ratio: auto;
}
.dx-add-cart {
max-width: 100%;
}
.dx-reviews-section,
.dx-qa-section {
h2 {
font-size: 1.3rem;
}
}
} }
// Mobile
@media (max-width: 768px) { @media (max-width: 768px) {
.dx-item-container { .dx-item-container {
padding: 16px; padding: 16px;
} }
// On mobile: thumbnails go below main photo .dx-item-content {
gap: 24px;
margin-bottom: 32px;
}
// Thumbnails go below main photo
.dx-gallery { .dx-gallery {
flex-direction: column; flex-direction: column;
max-width: 100%;
} }
.dx-thumbnails { .dx-thumbnails {
@@ -666,14 +806,16 @@ $dx-card-bg: #f5f3f9;
max-height: none; max-height: none;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
order: 1; // put below main photo order: 1;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar { display: none; } &::-webkit-scrollbar { display: none; }
} }
.dx-main-photo { .dx-main-photo {
order: 0; // main photo first order: 0;
max-height: 400px;
aspect-ratio: auto;
} }
.dx-thumb { .dx-thumb {
@@ -690,8 +832,32 @@ $dx-card-bg: #f5f3f9;
font-size: 1.8rem; font-size: 1.8rem;
} }
.dx-old-price {
font-size: 1rem;
}
.dx-add-cart { .dx-add-cart {
max-width: 100%; max-width: 100%;
padding: 14px 20px;
font-size: 1rem;
}
.dx-description {
h2 {
font-size: 1.15rem;
}
}
.dx-specs-table {
.spec-key {
white-space: normal;
width: auto;
}
td {
padding: 8px 10px;
font-size: 0.85rem;
}
} }
.dx-review-form { .dx-review-form {
@@ -707,21 +873,153 @@ $dx-card-bg: #f5f3f9;
width: 100%; width: 100%;
} }
} }
.dx-review-card {
padding: 16px;
}
.dx-reviews-section,
.dx-qa-section {
margin-bottom: 32px;
h2 {
font-size: 1.25rem;
margin-bottom: 16px;
}
}
.dx-qa-card {
padding: 16px;
}
.dx-question,
.dx-answer {
font-size: 0.9rem;
}
} }
// Small mobile
@media (max-width: 480px) { @media (max-width: 480px) {
.dx-item-container {
padding: 12px;
}
.dx-item-content {
gap: 20px;
margin-bottom: 24px;
}
.dx-main-photo {
max-height: 300px;
border-radius: 10px;
img, video {
padding: 8px;
}
}
.dx-thumb { .dx-thumb {
width: 56px; width: 52px;
height: 56px; height: 52px;
min-width: 56px; min-width: 52px;
} }
.dx-title { .dx-title {
font-size: 1.25rem; font-size: 1.2rem;
}
.dx-info {
gap: 16px;
} }
.dx-current-price { .dx-current-price {
font-size: 1.6rem; font-size: 1.5rem;
}
.dx-rating {
flex-wrap: wrap;
gap: 6px;
}
.dx-stock {
flex-wrap: wrap;
gap: 6px;
}
.dx-add-cart {
padding: 12px 16px;
font-size: 0.95rem;
border-radius: 10px;
}
.dx-review-form {
padding: 14px;
h3 {
font-size: 1rem;
}
}
.dx-star-selector {
.dx-star-pick {
font-size: 1.5rem;
}
}
.dx-textarea {
padding: 12px;
font-size: 0.9rem;
}
.dx-review-card {
padding: 14px;
}
.dx-reviewer-name {
font-size: 0.9rem;
}
.dx-review-text {
font-size: 0.9rem;
}
.dx-specs-table {
td {
padding: 6px 8px;
font-size: 0.8rem;
display: block;
}
.spec-key {
width: 100%;
padding-bottom: 2px;
}
.spec-value {
padding-top: 0;
}
tr {
display: flex;
flex-direction: column;
padding: 6px 0;
}
}
.dx-qa-card {
padding: 14px;
}
.dx-question,
.dx-answer {
font-size: 0.85rem;
gap: 8px;
}
.dx-qa-label {
width: 24px;
height: 24px;
font-size: 0.7rem;
} }
} }
@@ -1299,12 +1597,20 @@ $dx-card-bg: #f5f3f9;
} }
} }
// ========== NOVO RESPONSIVE ==========
// Tablet portrait
@media (max-width: 968px) { @media (max-width: 968px) {
.novo-item-content { .novo-item-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 2rem; gap: 2rem;
} }
.novo-gallery {
max-width: 600px;
margin: 0 auto;
}
.novo-info .novo-title { .novo-info .novo-title {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -1313,6 +1619,10 @@ $dx-card-bg: #f5f3f9;
font-size: 2rem; font-size: 2rem;
} }
.novo-info .novo-add-cart {
max-width: 100%;
}
.novo-review-form { .novo-review-form {
padding: 1.5rem; padding: 1.5rem;
@@ -1327,3 +1637,302 @@ $dx-card-bg: #f5f3f9;
} }
} }
} }
// Mobile
@media (max-width: 768px) {
.novo-item-container {
padding: 1rem;
}
.novo-item-content {
gap: 1.5rem;
margin-bottom: 2rem;
}
.novo-gallery {
max-width: 100%;
.novo-main-photo {
border-radius: var(--radius-lg);
margin-bottom: 0.75rem;
}
.novo-thumbnails {
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 0.5rem;
}
}
.novo-info {
.novo-title {
font-size: 1.35rem;
}
.novo-rating {
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.novo-price-block {
padding: 1rem;
.current-price {
font-size: 1.75rem;
}
.old-price {
font-size: 0.95rem;
}
}
.novo-stock {
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem;
}
.novo-add-cart {
padding: 1rem 1.5rem;
font-size: 1rem;
margin-bottom: 1.5rem;
}
.novo-description {
h3 {
font-size: 1.1rem;
}
}
}
.novo-specs-table {
.spec-key {
white-space: normal;
width: auto;
}
td {
padding: 8px 10px;
font-size: 0.85rem;
}
}
.novo-reviews {
margin-top: 2rem;
padding-top: 2rem;
h2 {
font-size: 1.35rem;
margin-bottom: 1.5rem;
}
}
.novo-review-card {
padding: 1rem;
}
.novo-review-form {
padding: 1.25rem;
h3 {
font-size: 1.1rem;
}
}
}
// Small mobile
@media (max-width: 480px) {
.novo-item-container {
padding: 0.75rem;
}
.novo-item-content {
gap: 1.25rem;
margin-bottom: 1.5rem;
}
.novo-gallery {
.novo-thumbnails {
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
gap: 0.4rem;
}
.novo-main-photo {
border-radius: var(--radius-md);
}
}
.novo-info {
.novo-title {
font-size: 1.15rem;
}
.novo-price-block {
padding: 0.75rem;
.current-price {
font-size: 1.5rem;
}
}
.novo-stock {
padding: 0.6rem;
font-size: 0.85rem;
}
.novo-add-cart {
padding: 0.85rem 1rem;
font-size: 0.95rem;
border-radius: var(--radius-md);
}
}
.novo-review-form {
padding: 1rem;
.novo-rating-input {
.novo-star-selector {
.novo-star {
font-size: 1.6rem;
}
}
}
.novo-textarea {
padding: 0.75rem;
font-size: 0.9rem;
}
}
.novo-review-card {
padding: 0.75rem;
.review-header {
flex-direction: column;
gap: 0.5rem;
.review-stars {
align-self: flex-start;
}
}
.review-text {
font-size: 0.9rem;
}
}
.novo-specs-table {
td {
padding: 6px 8px;
font-size: 0.8rem;
display: block;
}
.spec-key {
width: 100%;
padding-bottom: 2px;
}
.spec-value {
padding-top: 0;
}
tr {
display: flex;
flex-direction: column;
padding: 6px 0;
}
}
}
// ========== BADGES, TAGS & SPECS (shared) ==========
// Badges
.novo-badges, .dx-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0;
}
.item-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #fff;
&.badge-new { background: #4caf50; }
&.badge-sale { background: #f44336; }
&.badge-exclusive { background: #9c27b0; }
&.badge-hot { background: #ff5722; }
&.badge-limited { background: #ff9800; }
&.badge-bestseller { background: #2196f3; }
&.badge-featured { background: #607d8b; }
&.badge-custom { background: #78909c; }
}
// Tags
.novo-tags, .dx-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 6px 0 12px;
}
.item-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
color: #497671;
background: rgba(73, 118, 113, 0.08);
border: 1px solid rgba(73, 118, 113, 0.15);
}
// Specs table
.novo-specs-table, .dx-specs-table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
tr {
border-bottom: 1px solid #e8ecec;
&:last-child { border-bottom: none; }
}
td {
padding: 10px 12px;
font-size: 0.9rem;
vertical-align: top;
}
.spec-key {
color: #697777;
font-weight: 500;
width: 40%;
white-space: nowrap;
}
.spec-value {
color: #1e3c38;
}
}
// Simple description
.novo-simple-desc, .dx-simple-desc {
font-size: 0.95rem;
color: #697777;
line-height: 1.6;
margin-bottom: 16px;
}
// Stock quantity
.stock-qty, .dx-stock-qty {
font-size: 0.8rem;
color: #697777;
margin-left: 8px;
}

View File

@@ -1,14 +1,15 @@
import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, signal, computed, ChangeDetectionStrategy, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService, TelegramService, SeoService } from '../../services'; import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services';
import { Item } from '../../models'; import { AuthService } from '../../services/auth.service';
import { Item, ItemDetail, DescriptionField } from '../../models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { SecurityContext } from '@angular/core'; import { SecurityContext } from '@angular/core';
import { getDiscountedPrice } from '../../utils/item.utils'; import { getDiscountedPrice, getAllImages, getStockStatus, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service'; import { TranslateService } from '../../i18n/translate.service';
@@ -26,6 +27,55 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
loading = signal(true); loading = signal(true);
error = signal<string | null>(null); error = signal<string | null>(null);
isnovo = environment.theme === 'novo'; isnovo = environment.theme === 'novo';
// Variant selection
selectedColour = signal<string | null>(null);
selectedSize = signal<string | null>(null);
availableColours = computed(() => {
const details = this.item()?.itemDetails;
if (!details?.length) return [] as string[];
const unique = [...new Set(details.map(d => d.colour || d.color).filter((c): c is string => !!c))];
return unique;
});
availableSizes = computed(() => {
const details = this.item()?.itemDetails;
if (!details?.length) return [] as string[];
// If a colour is selected, only show sizes available for that colour
const colour = this.selectedColour();
const filtered = colour
? details.filter(d => (d.colour || d.color) === colour)
: details;
const unique = [...new Set(filtered.map(d => d.size).filter((s): s is string => !!s && s.toLowerCase() !== 'default'))];
return unique;
});
selectedDetail = computed<ItemDetail | null>(() => {
const details = this.item()?.itemDetails;
if (!details?.length) return null;
const colour = this.selectedColour();
const size = this.selectedSize();
return details.find(d =>
(!colour || (d.colour || d.color) === colour) &&
(!size || d.size === size)
) ?? null;
});
effectivePrice = computed(() => {
const detail = this.selectedDetail();
return detail?.price ?? this.item()?.price ?? 0;
});
effectiveCurrency = computed(() => {
const detail = this.selectedDetail();
return detail?.currency ?? this.item()?.currency ?? '';
});
effectiveRemaining = computed(() => {
const detail = this.selectedDetail();
return detail?.remaining ?? this.item()?.quantity ?? null;
});
newReview = { newReview = {
rating: 0, rating: 0,
@@ -42,13 +92,15 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
private seoService = inject(SeoService); private seoService = inject(SeoService);
private i18n = inject(TranslateService); private i18n = inject(TranslateService);
private authService = inject(AuthService);
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private apiService: ApiService, private apiService: ApiService,
private cartService: CartService, private cartService: CartService,
private telegramService: TelegramService, private telegramService: TelegramService,
private sanitizer: DomSanitizer private sanitizer: DomSanitizer,
private languageService: LanguageService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -72,6 +124,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
this.apiService.getItem(itemID).subscribe({ this.apiService.getItem(itemID).subscribe({
next: (item) => { next: (item) => {
this.item.set(item); this.item.set(item);
this.initVariantSelection(item);
this.seoService.setItemMeta(item); this.seoService.setItemMeta(item);
this.loading.set(false); this.loading.set(false);
}, },
@@ -83,6 +136,33 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
}); });
} }
private initVariantSelection(item: Item): void {
// Auto-select the first available colour and size from itemDetails
const details = item.itemDetails;
if (details?.length) {
const firstColour = details[0].colour || details[0].color || null;
this.selectedColour.set(firstColour);
const firstSize = details[0].size || null;
this.selectedSize.set(firstSize);
} else {
this.selectedColour.set(item.colour ?? null);
this.selectedSize.set(item.size ?? null);
}
}
selectColour(colour: string): void {
this.selectedColour.set(colour);
// If current size is not available for the new colour, reset to first available
const sizes = this.availableSizes();
if (sizes.length && this.selectedSize() && !sizes.includes(this.selectedSize()!)) {
this.selectedSize.set(sizes[0]);
}
}
selectSize(size: string): void {
this.selectedSize.set(size);
}
selectPhoto(index: number): void { selectPhoto(index: number): void {
this.selectedPhotoIndex.set(index); this.selectedPhotoIndex.set(index);
} }
@@ -90,16 +170,73 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
addToCart(): void { addToCart(): void {
const currentItem = this.item(); const currentItem = this.item();
if (currentItem) { if (currentItem) {
this.cartService.addItem(currentItem.itemID); this.cartService.addItem(currentItem.itemID, 1, {
colour: this.selectedColour() ?? undefined,
size: this.selectedSize() ?? undefined,
price: this.effectivePrice(),
currency: this.effectiveCurrency()
});
} }
} }
getDiscountedPrice(): number { getDiscountedPrice(): number {
const currentItem = this.item(); const currentItem = this.item();
if (!currentItem) return 0; if (!currentItem) return 0;
return getDiscountedPrice(currentItem); const price = this.effectivePrice();
const discount = currentItem.discount || 0;
return discount > 0 ? price * (1 - discount / 100) : price;
} }
// BackOffice integration helpers
getItemName(): string {
const currentItem = this.item();
if (!currentItem) return '';
const lang = this.languageService.currentLanguage();
return getTranslatedField(currentItem, 'name', lang);
}
getSimpleDescription(): string {
const currentItem = this.item();
if (!currentItem) return '';
return currentItem.simpleDescription || currentItem.description || '';
}
hasDescriptionFields(): boolean {
const currentItem = this.item();
return !!(currentItem?.descriptionFields && currentItem.descriptionFields.length > 0);
}
getTranslatedDescriptionFields(): DescriptionField[] {
const currentItem = this.item();
if (!currentItem) return [];
const lang = this.languageService.currentLanguage();
const translation = currentItem.translations?.[lang];
if (translation?.description && translation.description.length > 0) {
return translation.description;
}
return currentItem.descriptionFields || [];
}
getStockClass(): string {
const currentItem = this.item();
if (!currentItem) return 'high';
return getStockStatus(currentItem);
}
getStockLabel(): string {
const status = this.getStockClass();
switch (status) {
case 'high': return 'В наличии';
case 'medium': return 'Заканчивается';
case 'low': return 'Последние штуки';
case 'out': return 'Нет в наличии';
default: return 'В наличии';
}
}
readonly getBadgeClass = getBadgeClass;
getSafeHtml(html: string): SafeHtml { getSafeHtml(html: string): SafeHtml {
return this.sanitizer.sanitize(SecurityContext.HTML, html) || ''; return this.sanitizer.sanitize(SecurityContext.HTML, html) || '';
} }
@@ -155,8 +292,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
itemID: currentItem.itemID, itemID: currentItem.itemID,
rating: this.newReview.rating, rating: this.newReview.rating,
comment: this.newReview.comment.trim(), comment: this.newReview.comment.trim(),
username: this.newReview.anonymous ? null : this.getUserDisplayName(), sessionID: this.authService.session()?.sessionId || '',
userId: this.telegramService.getUserId(),
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };

View File

@@ -1,98 +1,102 @@
<h1>Company Details</h1> <div class="legal-page">
<div class="legal-container">
<section class="legal-section"> <h1>Company Details</h1>
<h2>Full Company Name</h2>
<p>LIMITED LIABILITY COMPANY «INT FIN LOGISTIC»</p> <section class="legal-section">
<p><strong>Abbreviated name:</strong> LLC «INT FIN LOGISTIC»</p> <h2>Full Company Name</h2>
</section> <p>LIMITED LIABILITY COMPANY «INT FIN LOGISTIC»</p>
<p><strong>Abbreviated name:</strong> LLC «INT FIN LOGISTIC»</p>
<section class="legal-section"> </section>
<h2>Legal Address</h2>
<p>ARMENIA, 2301, KOTAYK REGION, HRAZDAN, KHACHATRYAN st., 31, 4</p> <section class="legal-section">
</section> <h2>Legal Address</h2>
<p>ARMENIA, 2301, KOTAYK REGION, HRAZDAN, KHACHATRYAN st., 31, 4</p>
<section class="legal-section"> </section>
<h2>Actual Address</h2>
<p><strong>Office in Armenia:</strong> 0033, Yerevan, Orbeli Brothers St., 47</p> <section class="legal-section">
<p><strong>Office in Russia:</strong> 121059, Moscow, Taras Shevchenko Emb., 3/2</p> <h2>Actual Address</h2>
</section> <p><strong>Office in Armenia:</strong> 0033, Yerevan, Orbeli Brothers St., 47</p>
<p><strong>Office in Russia:</strong> 121059, Moscow, Taras Shevchenko Emb., 3/2</p>
<section class="legal-section"> </section>
<h2>Main Details</h2>
<div class="details-grid"> <section class="legal-section">
<div class="detail-item"> <h2>Main Details</h2>
<strong>TIN (RUS):</strong> <div class="details-grid">
<span>9909697628</span> <div class="detail-item">
</div> <strong>TIN (RUS):</strong>
<div class="detail-item"> <span>9909697628</span>
<strong>TIN (ARM):</strong> </div>
<span>03033502</span> <div class="detail-item">
</div> <strong>TIN (ARM):</strong>
<div class="detail-item"> <span>03033502</span>
<strong>KPP:</strong> </div>
<span>770287001</span> <div class="detail-item">
</div> <strong>KPP:</strong>
<div class="detail-item"> <span>770287001</span>
<strong>OGRN:</strong> </div>
<span>85.110.1408711</span> <div class="detail-item">
</div> <strong>OGRN:</strong>
<span>85.110.1408711</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Bank Details</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Bank:</strong>
<span>JSC "Raiffeisenbank"</span>
</div>
<div class="detail-item">
<strong>Settlement account:</strong>
<span>40807810500000002376</span>
</div>
<div class="detail-item">
<strong>Correspondent account:</strong>
<span>30101810200000000700</span>
</div>
<div class="detail-item">
<strong>BIC:</strong>
<span>044525700</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Contact Information</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Phone (RUS):</strong>
<span><a href="tel:+79264593157">+7 (926) 459-31-57</a></span>
</div>
<div class="detail-item">
<strong>Phone (ARM):</strong>
<span><a href="tel:+37494861816">+374 94 86 18 16</a></span>
</div>
<div class="detail-item">
<strong>Email:</strong>
<span><a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Website:</strong>
<span><a href="https://dexarmarket.ru" target="_blank">dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Office hours:</strong>
<span>10:00 - 19:00 (MSK)</span>
</div>
<div class="detail-item">
<strong>Support:</strong>
<span>24/7</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Management</h2>
<p><strong>General Director:</strong> Hovhannisyan Ashot Rafikovich</p>
<p><strong>Basis of authority:</strong> Charter</p>
</section>
</div> </div>
</section> </div>
<section class="legal-section">
<h2>Bank Details</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Bank:</strong>
<span>JSC "Raiffeisenbank"</span>
</div>
<div class="detail-item">
<strong>Settlement account:</strong>
<span>40807810500000002376</span>
</div>
<div class="detail-item">
<strong>Correspondent account:</strong>
<span>30101810200000000700</span>
</div>
<div class="detail-item">
<strong>BIC:</strong>
<span>044525700</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Contact Information</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Phone (RUS):</strong>
<span><a href="tel:+79264593157">+7 (926) 459-31-57</a></span>
</div>
<div class="detail-item">
<strong>Phone (ARM):</strong>
<span><a href="tel:+37494861816">+374 94 86 18 16</a></span>
</div>
<div class="detail-item">
<strong>Email:</strong>
<span><a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Website:</strong>
<span><a href="https://dexarmarket.ru" target="_blank">dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Office hours:</strong>
<span>10:00 - 19:00 (MSK)</span>
</div>
<div class="detail-item">
<strong>Support:</strong>
<span>24/7</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Management</h2>
<p><strong>General Director:</strong> Hovhannisyan Ashot Rafikovich</p>
<p><strong>Basis of authority:</strong> Charter</p>
</section>

View File

@@ -1,98 +1,102 @@
<h1>Կազմակերպության տվյալներ</h1> <div class="legal-page">
<div class="legal-container">
<section class="legal-section"> <h1>Կազմակերպության տվյալներ</h1>
<h2>Կազմակերպության լիարժեկ անվանումը</h2>
<p>ՍԱՀՄԱՆԱՓԱԿ ՊԱՏԱՍԽԱՆԱՏվությամբ Ընկերություն «ԻՆՏ ՖԻՆ ԼՈԳԻՍՏԻԿ»</p> <section class="legal-section">
<p><strong>Հապավոր անվանումը՝</strong> ՍՊԸ «ԻՆՏ ՖԻՆ ԼՈԳԻՍՏԻԿ»</p> <h2>Կազմակերպության լիարժեկ անվանումը</h2>
</section> <p>ՍԱՀՄԱՆԱՓԱԿ ՊԱՏԱՍԽԱՆԱՏվությամբ Ընկերություն «ԻՆՏ ՖԻՆ ԼՈԳԻՍՏԻԿ»</p>
<p><strong>Հապավոր անվանումը՝</strong> ՍՊԸ «ԻՆՏ ՖԻՆ ԼՈԳԻՍՏԻԿ»</p>
<section class="legal-section"> </section>
<h2>Իրավաբանական հասցե</h2>
<p>ՀԱՅԱՍՏԱՆ, 2301, ԿՈՏԱՅԿԻ ՄԱՐԶ, ՀՐԱԶԴԱՆ, ԽԱՉԱՏՐՅԱՆ փկ., 31, 4</p> <section class="legal-section">
</section> <h2>Իրավաբանական հասցե</h2>
<p>ՀԱՅԱՍՏԱՆ, 2301, ԿՈՏԱՅԿԻ ՄԱՐԶ, ՀՐԱԶԴԱՆ, ԽԱՉԱՏՐՅԱՆ փկ., 31, 4</p>
<section class="legal-section"> </section>
<h2>Գործնական հասցե</h2>
<p><strong>Գրասենյակ Հայաստանում՝</strong> 0033, Երևան, Եղբայրներ Օրբելի փկ., 47</p> <section class="legal-section">
<p><strong>Գրասենյակ Ռուսաստանում՝</strong> 121059, Մոսկվա, Տարաս Շևչենկոի փակ., 3կ2</p> <h2>Գործնական հասցե</h2>
</section> <p><strong>Գրասենյակ Հայաստանում՝</strong> 0033, Երևան, Եղբայրներ Օրբելի փկ., 47</p>
<p><strong>Գրասենյակ Ռուսաստանում՝</strong> 121059, Մոսկվա, Տարաս Շևչենկոի փակ., 3կ2</p>
<section class="legal-section"> </section>
<h2>Հիմնական մանրամասներ</h2>
<div class="details-grid"> <section class="legal-section">
<div class="detail-item"> <h2>Հիմնական մանրամասներ</h2>
<strong>ՀՎՀՀ (RUS)՝</strong> <div class="details-grid">
<span>9909697628</span> <div class="detail-item">
</div> <strong>ՀՎՀՀ (RUS)՝</strong>
<div class="detail-item"> <span>9909697628</span>
<strong>ՀՎՀՀ (ARM)՝</strong> </div>
<span>03033502</span> <div class="detail-item">
</div> <strong>ՀՎՀՀ (ARM)՝</strong>
<div class="detail-item"> <span>03033502</span>
<strong>ԿՊՊ՝</strong> </div>
<span>770287001</span> <div class="detail-item">
</div> <strong>ԿՊՊ՝</strong>
<div class="detail-item"> <span>770287001</span>
<strong>ՊԳՐՆ՝</strong> </div>
<span>85.110.1408711</span> <div class="detail-item">
</div> <strong>ՊԳՐՆ՝</strong>
<span>85.110.1408711</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Բանկային տվյալներ</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Բանկ՝</strong>
<span>«Ռայֆֆայզենբանկ» ԲԲ</span>
</div>
<div class="detail-item">
<strong>Հաշվարկային հաշիվ՝</strong>
<span>40807810500000002376</span>
</div>
<div class="detail-item">
<strong>Թղակցային հաշիվ՝</strong>
<span>30101810200000000700</span>
</div>
<div class="detail-item">
<strong>ԲԻԿ՝</strong>
<span>044525700</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Կապի տեղեկատվություն</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Հեռախոս (RUS)՝</strong>
<span><a href="tel:+79264593157">+7 (926) 459-31-57</a></span>
</div>
<div class="detail-item">
<strong>Հեռախոս (ARM)՝</strong>
<span><a href="tel:+37494861816">+374 94 86 18 16</a></span>
</div>
<div class="detail-item">
<strong>Էլ. փոստ՝</strong>
<span><a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Կայք՝</strong>
<span><a href="https://dexarmarket.ru" target="_blank">dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Գրասենյակի աշխատանքային ժամեր՝</strong>
<span>10:00 - 19:00 (ՄՍԿ)</span>
</div>
<div class="detail-item">
<strong>Տեխնիկական աջակցություն՝</strong>
<span>24/7</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Ղեկավարություն</h2>
<p><strong>Գլխավոր տնօրեն՝</strong> Օհաննիսյան Աշոտ Ռաֆիկի</p>
<p><strong>Գործողության հիմք՝</strong> Կանոնադրություն</p>
</section>
</div> </div>
</section> </div>
<section class="legal-section">
<h2>Բանկային տվյալներ</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Բանկ՝</strong>
<span>«Ռայֆֆայզենբանկ» ԲԲ</span>
</div>
<div class="detail-item">
<strong>Հաշվարկային հաշիվ՝</strong>
<span>40807810500000002376</span>
</div>
<div class="detail-item">
<strong>Թղակցային հաշիվ՝</strong>
<span>30101810200000000700</span>
</div>
<div class="detail-item">
<strong>ԲԻԿ՝</strong>
<span>044525700</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Կապի տեղեկատվություն</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Հեռախոս (RUS)՝</strong>
<span><a href="tel:+79264593157">+7 (926) 459-31-57</a></span>
</div>
<div class="detail-item">
<strong>Հեռախոս (ARM)՝</strong>
<span><a href="tel:+37494861816">+374 94 86 18 16</a></span>
</div>
<div class="detail-item">
<strong>Էլ. փոստ՝</strong>
<span><a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Կայք՝</strong>
<span><a href="https://dexarmarket.ru" target="_blank">dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Գրասենյակի աշխատանքային ժամեր՝</strong>
<span>10:00 - 19:00 (ՄՍԿ)</span>
</div>
<div class="detail-item">
<strong>Տեխնիկական աջակցություն՝</strong>
<span>24/7</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Ղեկավարություն</h2>
<p><strong>Գլխավոր տնօրեն՝</strong> Օհաննիսյան Աշոտ Ռաֆիկի</p>
<p><strong>Գործողության հիմք՝</strong> Կանոնադրություն</p>
</section>

View File

@@ -1,98 +1,102 @@
<h1>Реквизиты организации</h1> <div class="legal-page">
<div class="legal-container">
<section class="legal-section"> <h1>Реквизиты организации</h1>
<h2>Полное наименование организации</h2>
<p>ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ «ИНТ ФИН ЛОГИСТИК»</p> <section class="legal-section">
<p><strong>Сокращенное наименование:</strong> ООО «ИНТ ФИН ЛОГИСТИК»</p> <h2>Полное наименование организации</h2>
</section> <p>ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ «ИНТ ФИН ЛОГИСТИК»</p>
<p><strong>Сокращенное наименование:</strong> ООО «ИНТ ФИН ЛОГИСТИК»</p>
<section class="legal-section"> </section>
<h2>Юридический адрес</h2>
<p>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p> <section class="legal-section">
</section> <h2>Юридический адрес</h2>
<p>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
<section class="legal-section"> </section>
<h2>Фактический адрес</h2>
<p><strong>Офис в Армении:</strong> 0033, Ереван, улица Братьев Орбели, 47</p> <section class="legal-section">
<p><strong>Офис в России:</strong> 121059, Москва, наб. Тараса Шевченко, 3к2</p> <h2>Фактический адрес</h2>
</section> <p><strong>Офис в Армении:</strong> 0033, Ереван, улица Братьев Орбели, 47</p>
<p><strong>Офис в России:</strong> 121059, Москва, наб. Тараса Шевченко, 3к2</p>
<section class="legal-section"> </section>
<h2>Основные реквизиты</h2>
<div class="details-grid"> <section class="legal-section">
<div class="detail-item"> <h2>Основные реквизиты</h2>
<strong>ИНН (RUS):</strong> <div class="details-grid">
<span>9909697628</span> <div class="detail-item">
</div> <strong>ИНН (RUS):</strong>
<div class="detail-item"> <span>9909697628</span>
<strong>ИНН (ARM):</strong> </div>
<span>03033502</span> <div class="detail-item">
</div> <strong>ИНН (ARM):</strong>
<div class="detail-item"> <span>03033502</span>
<strong>КПП:</strong> </div>
<span>770287001</span> <div class="detail-item">
</div> <strong>КПП:</strong>
<div class="detail-item"> <span>770287001</span>
<strong>ОГРН:</strong> </div>
<span>85.110.1408711</span> <div class="detail-item">
</div> <strong>ОГРН:</strong>
<span>85.110.1408711</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Банковские реквизиты</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Банк:</strong>
<span>АО "Райффайзенбанк"</span>
</div>
<div class="detail-item">
<strong>Расчетный счет:</strong>
<span>40807810500000002376</span>
</div>
<div class="detail-item">
<strong>Корр. счет:</strong>
<span>30101810200000000700</span>
</div>
<div class="detail-item">
<strong>БИК:</strong>
<span>044525700</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Контактная информация</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Телефон (RUS):</strong>
<span><a href="tel:+79264593157">+7 (926) 459-31-57</a></span>
</div>
<div class="detail-item">
<strong>Телефон (ARM):</strong>
<span><a href="tel:+37494861816">+374 94 86 18 16</a></span>
</div>
<div class="detail-item">
<strong>Email:</strong>
<span><a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Сайт:</strong>
<span><a href="https://dexarmarket.ru" target="_blank">dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Часы работы офиса:</strong>
<span>10:00 - 19:00 (МСК)</span>
</div>
<div class="detail-item">
<strong>Техподдержка:</strong>
<span>24/7</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Руководство</h2>
<p><strong>Генеральный директор:</strong> Оганнисян Ашот Рафикович</p>
<p><strong>Основание действий:</strong> Устав</p>
</section>
</div> </div>
</section> </div>
<section class="legal-section">
<h2>Банковские реквизиты</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Банк:</strong>
<span>АО "Райффайзенбанк"</span>
</div>
<div class="detail-item">
<strong>Расчетный счет:</strong>
<span>40807810500000002376</span>
</div>
<div class="detail-item">
<strong>Корр. счет:</strong>
<span>30101810200000000700</span>
</div>
<div class="detail-item">
<strong>БИК:</strong>
<span>044525700</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Контактная информация</h2>
<div class="details-grid">
<div class="detail-item">
<strong>Телефон (RUS):</strong>
<span><a href="tel:+79264593157">+7 (926) 459-31-57</a></span>
</div>
<div class="detail-item">
<strong>Телефон (ARM):</strong>
<span><a href="tel:+37494861816">+374 94 86 18 16</a></span>
</div>
<div class="detail-item">
<strong>Email:</strong>
<span><a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Сайт:</strong>
<span><a href="https://dexarmarket.ru" target="_blank">dexarmarket.ru</a></span>
</div>
<div class="detail-item">
<strong>Часы работы офиса:</strong>
<span>10:00 - 19:00 (МСК)</span>
</div>
<div class="detail-item">
<strong>Техподдержка:</strong>
<span>24/7</span>
</div>
</div>
</section>
<section class="legal-section">
<h2>Руководство</h2>
<p><strong>Генеральный директор:</strong> Оганнисян Ашот Рафикович</p>
<p><strong>Основание действий:</strong> Устав</p>
</section>

View File

@@ -1,113 +1,117 @@
<h1>Payment Terms</h1> <div class="legal-page">
<div class="legal-container">
<section class="legal-section"> <h1>Payment Terms</h1>
<h2>1. General Provisions</h2>
<p>1.1. These Terms define the payment procedures for Goods and Services purchased by Buyers through the DexarMarket Marketplace.</p> <section class="legal-section">
<p>1.2. Payment is made for Goods/Services listed by independent Sellers. The Marketplace acts as an information intermediary and provides the technical infrastructure for processing payments.</p> <h2>1. General Provisions</h2>
<p>1.3. Payment for goods and services on the Marketplace is made in Russian rubles (RUB).</p> <p>1.1. These Terms define the payment procedures for Goods and Services purchased by Buyers through the DexarMarket Marketplace.</p>
<p>1.4. Prices for Goods/Services are set by Sellers independently and are indicated on the respective Goods/Services page.</p> <p>1.2. Payment is made for Goods/Services listed by independent Sellers. The Marketplace acts as an information intermediary and provides the technical infrastructure for processing payments.</p>
</section> <p>1.3. Payment for goods and services on the Marketplace is made in Russian rubles (RUB).</p>
<p>1.4. Prices for Goods/Services are set by Sellers independently and are indicated on the respective Goods/Services page.</p>
<section class="legal-section"> </section>
<h2>2. Payment Methods</h2>
<p>2.1. The Marketplace supports the following payment methods:</p> <section class="legal-section">
<h2>2. Payment Methods</h2>
<div class="payment-methods"> <p>2.1. The Marketplace supports the following payment methods:</p>
<div class="payment-logos">
<img src="/assets/images/mir-logo.svg" alt="MIR" loading="lazy" width="80" height="50" /> <div class="payment-methods">
<img src="/assets/images/visa-logo.svg" alt="Visa" loading="lazy" width="80" height="50" /> <div class="payment-logos">
<img src="/assets/images/mastercard-logo.svg" alt="Mastercard" loading="lazy" width="80" height="50" /> <img src="/assets/images/mir-logo.svg" alt="MIR" loading="lazy" width="80" height="50" />
</div> <img src="/assets/images/visa-logo.svg" alt="Visa" loading="lazy" width="80" height="50" />
<img src="/assets/images/mastercard-logo.svg" alt="Mastercard" loading="lazy" width="80" height="50" />
</div>
</div>
<ul>
<li><strong>Bank cards:</strong> Visa, Mastercard, MIR</li>
<li><strong>Fast Payment System (FPS):</strong> instant transfer via mobile banking app</li>
<li><strong>E-wallets:</strong> YooMoney, QIWI (if available)</li>
<li><strong>Payment by link:</strong> generation of a unique payment link for each order</li>
</ul>
<p>2.2. Available payment methods may vary depending on the Seller and type of Goods/Services.</p>
<p>2.3. All payments are processed through certified payment systems in compliance with PCI DSS security standards.</p>
</section>
<section class="legal-section">
<h2>3. Payment Process</h2>
<p>3.1. The order payment procedure includes the following steps:</p>
<ol>
<li>Selecting Goods/Services and adding them to the cart</li>
<li>Placing an order with contact details and delivery method (if applicable)</li>
<li>Choosing a payment method from available options</li>
<li>Redirecting to a secure payment system page or receiving a payment link</li>
<li>Entering payment details and confirming payment</li>
<li>Receiving a successful payment notification</li>
</ol>
<p>3.2. When paying by bank card, the Buyer may be redirected to the issuing bank's page for additional authentication (3D-Secure).</p>
<p>3.3. The Buyer's payment obligation is considered fulfilled upon receipt of funds by the payment system.</p>
</section>
<section class="legal-section">
<h2>4. Payment Security</h2>
<p>4.1. All payments are processed through a secure HTTPS connection using TLS 1.2 protocol and above.</p>
<p>4.2. The Marketplace does not store full bank card data of Buyers. Payment data processing is carried out by certified payment aggregators.</p>
<p>4.3. 3D-Secure technology is used to protect against fraud, requiring payment confirmation via SMS code or push notification from the bank.</p>
<p>4.4. In case of suspicious activity, the payment system reserves the right to request additional identity verification of the Buyer.</p>
</section>
<section class="legal-section">
<h2>5. Payment Confirmation</h2>
<p>5.1. After successful payment, the Buyer receives a confirmation to the email address provided during order placement.</p>
<p>5.2. The confirmation contains the following information:</p>
<ul>
<li>Order number</li>
<li>Date and time of payment</li>
<li>Payment amount</li>
<li>Order contents</li>
<li>Seller contact details</li>
</ul>
<p>5.3. Order information is also displayed in the Buyer's personal account on the Marketplace (if registered).</p>
<p>5.4. A fiscal receipt is sent by the Seller in accordance with the requirements of RF legislation.</p>
</section>
<section class="legal-section">
<h2>6. Refunds</h2>
<p>6.1. The refund procedure is governed by the <a [routerLink]="'/return-policy' | langRoute">Return Policy</a> and depends on the type of Goods/Services purchased.</p>
<p>6.2. Refunds are made to the same payment instrument used for the original payment.</p>
<p>6.3. Refund processing times are as follows:</p>
<ul>
<li>To a bank card: 3 to 30 banking days (depending on the issuing bank)</li>
<li>To an e-wallet: 1 to 5 business days</li>
<li>Via FPS: 1 to 3 business days</li>
</ul>
<p>6.4. The Marketplace does not charge a commission for processing refunds. Payment system and bank fees may apply in accordance with their tariffs.</p>
</section>
<section class="legal-section">
<h2>7. Failed Payments</h2>
<p>7.1. A payment may be declined for the following reasons:</p>
<ul>
<li>Insufficient funds in the account</li>
<li>Incorrectly entered payment details</li>
<li>Card is blocked or expired</li>
<li>Transaction limits set by the bank have been exceeded</li>
<li>Transaction rejected by the security system</li>
</ul>
<p>7.2. In case of an unsuccessful payment, the Buyer receives a notification indicating the reason for the decline.</p>
<p>7.3. If you experience payment issues, it is recommended to:</p>
<ul>
<li>Verify the accuracy of the entered data</li>
<li>Contact the card-issuing bank to clarify the reason for the decline</li>
<li>Try an alternative payment method</li>
<li>Contact support: <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></li>
</ul>
</section>
<section class="legal-section">
<h2>8. Payment Inquiries Contact</h2>
<p>For questions related to order payments, you can contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</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>
<p>When contacting us, please provide your order number and a brief description of the issue for a faster resolution.</p>
</section>
</div> </div>
</div>
<ul>
<li><strong>Bank cards:</strong> Visa, Mastercard, MIR</li>
<li><strong>Fast Payment System (FPS):</strong> instant transfer via mobile banking app</li>
<li><strong>E-wallets:</strong> YooMoney, QIWI (if available)</li>
<li><strong>Payment by link:</strong> generation of a unique payment link for each order</li>
</ul>
<p>2.2. Available payment methods may vary depending on the Seller and type of Goods/Services.</p>
<p>2.3. All payments are processed through certified payment systems in compliance with PCI DSS security standards.</p>
</section>
<section class="legal-section">
<h2>3. Payment Process</h2>
<p>3.1. The order payment procedure includes the following steps:</p>
<ol>
<li>Selecting Goods/Services and adding them to the cart</li>
<li>Placing an order with contact details and delivery method (if applicable)</li>
<li>Choosing a payment method from available options</li>
<li>Redirecting to a secure payment system page or receiving a payment link</li>
<li>Entering payment details and confirming payment</li>
<li>Receiving a successful payment notification</li>
</ol>
<p>3.2. When paying by bank card, the Buyer may be redirected to the issuing bank's page for additional authentication (3D-Secure).</p>
<p>3.3. The Buyer's payment obligation is considered fulfilled upon receipt of funds by the payment system.</p>
</section>
<section class="legal-section">
<h2>4. Payment Security</h2>
<p>4.1. All payments are processed through a secure HTTPS connection using TLS 1.2 protocol and above.</p>
<p>4.2. The Marketplace does not store full bank card data of Buyers. Payment data processing is carried out by certified payment aggregators.</p>
<p>4.3. 3D-Secure technology is used to protect against fraud, requiring payment confirmation via SMS code or push notification from the bank.</p>
<p>4.4. In case of suspicious activity, the payment system reserves the right to request additional identity verification of the Buyer.</p>
</section>
<section class="legal-section">
<h2>5. Payment Confirmation</h2>
<p>5.1. After successful payment, the Buyer receives a confirmation to the email address provided during order placement.</p>
<p>5.2. The confirmation contains the following information:</p>
<ul>
<li>Order number</li>
<li>Date and time of payment</li>
<li>Payment amount</li>
<li>Order contents</li>
<li>Seller contact details</li>
</ul>
<p>5.3. Order information is also displayed in the Buyer's personal account on the Marketplace (if registered).</p>
<p>5.4. A fiscal receipt is sent by the Seller in accordance with the requirements of RF legislation.</p>
</section>
<section class="legal-section">
<h2>6. Refunds</h2>
<p>6.1. The refund procedure is governed by the <a [routerLink]="'/return-policy' | langRoute">Return Policy</a> and depends on the type of Goods/Services purchased.</p>
<p>6.2. Refunds are made to the same payment instrument used for the original payment.</p>
<p>6.3. Refund processing times are as follows:</p>
<ul>
<li>To a bank card: 3 to 30 banking days (depending on the issuing bank)</li>
<li>To an e-wallet: 1 to 5 business days</li>
<li>Via FPS: 1 to 3 business days</li>
</ul>
<p>6.4. The Marketplace does not charge a commission for processing refunds. Payment system and bank fees may apply in accordance with their tariffs.</p>
</section>
<section class="legal-section">
<h2>7. Failed Payments</h2>
<p>7.1. A payment may be declined for the following reasons:</p>
<ul>
<li>Insufficient funds in the account</li>
<li>Incorrectly entered payment details</li>
<li>Card is blocked or expired</li>
<li>Transaction limits set by the bank have been exceeded</li>
<li>Transaction rejected by the security system</li>
</ul>
<p>7.2. In case of an unsuccessful payment, the Buyer receives a notification indicating the reason for the decline.</p>
<p>7.3. If you experience payment issues, it is recommended to:</p>
<ul>
<li>Verify the accuracy of the entered data</li>
<li>Contact the card-issuing bank to clarify the reason for the decline</li>
<li>Try an alternative payment method</li>
<li>Contact support: <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></li>
</ul>
</section>
<section class="legal-section">
<h2>8. Payment Inquiries Contact</h2>
<p>For questions related to order payments, you can contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</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>
<p>When contacting us, please provide your order number and a brief description of the issue for a faster resolution.</p>
</section>

View File

@@ -1,113 +1,117 @@
<h1>Վճարման կանոններ</h1> <div class="legal-page">
<div class="legal-container">
<section class="legal-section"> <h1>Վճարման կանոններ</h1>
<h2>1. Ընդհանուր դրույթներ</h2>
<p>1.1. Սույն Կանոնները սահմանում են DexarMarket Մարկետփլեյսի միջոցով Գնորդների կողմից ձեռք բերված Ապրանքների և Ծառայությունների վճարման կարգը։</p> <section class="legal-section">
<p>1.2. Վճարումը կատարվում է անկախ Վաճառողների կողմից տեղադրված Ապրանքների/Ծառայությունների համար։ Մարկետփլեյսը հանդես է գալիս որպես տեղեկատվական միջնորդ և ապահովում է վճարումների կատարման տեխնիկական ենթակառուցվածքը։</p> <h2>1. Ընդհանուր դրույթներ</h2>
<p>1.3. Մարկետփլեյսում ապրանքների և ծառայությունների վճարումը կատարվում է ռուսական ռուբլով (RUB)։</p> <p>1.1. Սույն Կանոնները սահմանում են DexarMarket Մարկետփլեյսի միջոցով Գնորդների կողմից ձեռք բերված Ապրանքների և Ծառայությունների վճարման կարգը։</p>
<p>1.4. Ապրանքների/Ծառայությունների գները սահմանվում են Վաճառողների կողմից ինքնուրույն և նշված են համապատասխան Ապրանքի/Ծառայության էջում։</p> <p>1.2. Վճարումը կատարվում է անկախ Վաճառողների կողմից տեղադրված Ապրանքների/Ծառայությունների համար։ Մարկետփլեյսը հանդես է գալիս որպես տեղեկատվական միջնորդ և ապահովում է վճարումների կատարման տեխնիկական ենթակառուցվածքը։</p>
</section> <p>1.3. Մարկետփլեյսում ապրանքների և ծառայությունների վճարումը կատարվում է ռուսական ռուբլով (RUB)։</p>
<p>1.4. Ապրանքների/Ծառայությունների գները սահմանվում են Վաճառողների կողմից ինքնուրույն և նշված են համապատասխան Ապրանքի/Ծառայության էջում։</p>
<section class="legal-section"> </section>
<h2>2. Վճարման եղանակներ</h2>
<p>2.1. Մարկետփլեյսը աջակցում է վճարման հետևյալ եղանակները՝</p> <section class="legal-section">
<h2>2. Վճարման եղանակներ</h2>
<div class="payment-methods"> <p>2.1. Մարկետփլեյսը աջակցում է վճարման հետևյալ եղանակները՝</p>
<div class="payment-logos">
<img src="/assets/images/mir-logo.svg" alt="ՄԻՐ" loading="lazy" width="80" height="50" /> <div class="payment-methods">
<img src="/assets/images/visa-logo.svg" alt="Visa" loading="lazy" width="80" height="50" /> <div class="payment-logos">
<img src="/assets/images/mastercard-logo.svg" alt="Mastercard" loading="lazy" width="80" height="50" /> <img src="/assets/images/mir-logo.svg" alt="ՄԻՐ" loading="lazy" width="80" height="50" />
</div> <img src="/assets/images/visa-logo.svg" alt="Visa" loading="lazy" width="80" height="50" />
<img src="/assets/images/mastercard-logo.svg" alt="Mastercard" loading="lazy" width="80" height="50" />
</div>
</div>
<ul>
<li><strong>Բանկային քարտեր՝</strong> Visa, Mastercard, ՄԻՐ</li>
<li><strong>Արագ վճարման համակարգ (ՍԲՊ)՝</strong> ակնթարթ փոխանցում բանկի բջջային հավելվածի միջոցով</li>
<li><strong>Էլեկտրոնային դրամապանակներ՝</strong> ՅուMoney, QIWI (առկայության դեպքում)</li>
<li><strong>Վճարում հղման միջոցով՝</strong> յուրաքանչյուր պատվերի համար եզակի վճարման հղման ստեղծում</li>
</ul>
<p>2.2. Հասանելի վճարման եղանակները կարող են տարբերվել կախված Վաճառողից և Ապրանքի/Ծառայության տեսակից։</p>
<p>2.3. Բոլոր վճարումները մշակվում են սերտիֆիկացված վճարային համակարգերի միջոցով՝ PCI DSS անվտանգության ստանդարտներին համապատասխան։</p>
</section>
<section class="legal-section">
<h2>3. Վճարման գործընթացը</h2>
<p>3.1. Պատվերի վճարման գործընթացը ներառում է հետևյալ քայլերը՝</p>
<ol>
<li>Ապրանքների/Ծառայությունների ընտրություն և դրանց ավելացնել զամբյուղի մեջ</li>
<li>Պատվերի ձևակերպում՝ կապի տվյալների և առաքման եղանակի նշումով (անհրաժեշտության դեպքում)</li>
<li>Վճարման եղանակի ընտրություն հասանելի տարբերակներից</li>
<li>Վերահղորդում վճարային համակարգի պաշտպանված էջ կամ վճարման հղման ստացում</li>
<li>Վճարման տվյալների մուտքագրում և վճարման հաստատում</li>
<li>Հաջող վճարման մասին ծանուցման ստացում</li>
</ol>
<p>3.2. Բանկային քարտով վճարելիս Գնորդը կարող է վերահղորդվել թողարկող բանկի էջ՝ լրացուցիչ նույնականացման համար (3D-Secure)։</p>
<p>3.3. Գնորդի վճարման պարտավորությունը համարվում է կատարված վճարային համակարգի հաշվին դրամական միջոցների մուտքի պահից։</p>
</section>
<section class="legal-section">
<h2>4. Վճարումների անվտանգություն</h2>
<p>4.1. Բոլոր վճարումները մշակվում են պաշտպանված HTTPS կապի միջոցով՝ TLS 1.2 և ավելի բարձր պրոտոկոլի օգտագործմամբ։</p>
<p>4.2. Մարկետփլեյսը չի պահպանում Գնորդների բանկային քարտերի լիարժեկ տվյալները։ Վճարային տվյալների մշակումը կատարվում է սերտիֆիկացված վճարային ագրեգատորների կողմից։</p>
<p>4.3. Խարդախությունից պաշտպանության համար կիրառվում է 3D-Secure տեխնոլոգիան՝ վճարումը հաստատելու համար SMS կոդի կամ բանկի push ծանուցման միջոցով։</p>
<p>4.4. Կասկածելի գործողության դեպքում վճարային համակարգը իրավունք ունի պահանջելու Գնորդի ինքնության լրացուցիչ ստուգում։</p>
</section>
<section class="legal-section">
<h2>5. Վճարման հաստատում</h2>
<p>5.1. Հաջող վճարմանից հետո Գնորդը ստանում է հաստատում պատվերի ձևակերպման ժամանակ նշված էլեկտրոնային փոստի հասցեին։</p>
<p>5.2. Հաստատումը պարունակում է հետևյալ տեղեկատվությունը՝</p>
<ul>
<li>Պատվերի համարը</li>
<li>Վճարման ամսաթիվը և ժամը</li>
<li>Վճարման գումարը</li>
<li>Պատվերի բովանդակությունը</li>
<li>Վաճառողի կապի տվյալները</li>
</ul>
<p>5.3. Պատվերի մասին տեղեկատվությունը նաև ցուցադրվում է Գնորդի անձնական հաշվից Մարկետփլեյսում (գրանցման դեպքում)։</p>
<p>5.4. Ֆիսկալ կտրոնը ուղարկվում է Վաճառողի կողմից՝ ՌՀ օրենսդրության պահանջներին համապատասխան։</p>
</section>
<section class="legal-section">
<h2>6. Միջոցների վերադարձ</h2>
<p>6.1. Դրամական միջոցների վերադարձի կարգը կարգավորվում է <a [routerLink]="'/return-policy' | langRoute">Վերադարձի քաղաքականությամբ</a> և կախված է ձեռք բերված Ապրանքի/Ծառայության տեսակից։</p>
<p>6.2. Միջոցների վերադարձը կատարվում է նույն վճարային գործիքին՝ որից կատարվել էր վճարումը։</p>
<p>6.3. Դրամական միջոցների վերադարձի ժամկետները՝</p>
<ul>
<li>Բանկային քարտին՝ 3-ից 30 բանկային օր (կախված թողարկող բանկից)</li>
<li>Էլեկտրոնային դրամապանակին՝ 1-ից 5 աշխատանքային օր</li>
<li>ՍԲՊ-ի միջոցով՝ 1-ից 3 աշխատանքային օր</li>
</ul>
<p>6.4. Վերադարձի մշակման համար Մարկետփլեյսը միջնորդավճար չի գանձում։ Վճարային համակարգերի և բանկերի միջնորդավճարները կարող են կիրառվել իրենց սակագներին համապատասխան։</p>
</section>
<section class="legal-section">
<h2>7. Անհաջող վճարումներ</h2>
<p>7.1. Վճարումը կարող է մերժվել հետևյալ պատճառներով՝</p>
<ul>
<li>Հաշվին անբավարար միջոցներ</li>
<li>Վճարային տվյալները սխալ են մուտքագրված</li>
<li>Քարտը արգելափակված է կամ ժամկետն անցած է</li>
<li>Բանկի կողմից սահմանված գործարքների սահմանափակումները գերազանցված են</li>
<li>Անվտանգության համակարգի կողմից գործարքի մերժում</li>
</ul>
<p>7.2. Անհաջող վճարման դեպքում Գնորդը ստանում է ծանուցում՝ մերժման պատճառի նշումով։</p>
<p>7.3. Վճարման խնդիրների դեպքում խորհուրդ է տրվում՝</p>
<ul>
<li>Ստուգել մուտքագրված տվյալների ճշտությունը</li>
<li>Կապվել քարտ թողարկող բանկի հետ՝ մերժման պատճառը ճշտելու համար</li>
<li>Փորձել այլընտրանքային վճարման եղանակ</li>
<li>Դիմել աջակցության ծառայությանը՝ <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></li>
</ul>
</section>
<section class="legal-section">
<h2>8. Վճարման հարցերի կապի տեղեկատվություն</h2>
<p>Պատվերների վճարման հետ կապված հարցերի համար կարող եք դիմել՝</p>
<ul>
<li><strong>Էլ. փոստ՝</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></li>
<li><strong>Աշխատանքային ժամեր՝</strong> Շուրջօրյա (տեխնիկական աջակցություն)</li>
<li><strong>Միջին պատասխանի ժամը՝</strong> Մինչև 24 ժամ աշխատանքային օրերին</li>
</ul>
<p>Դիմելիս նշեք պատվերի համարը և խնդրի հակիրճ նկարագրությունը՝ հարցի ավելի արագ լուծման համար։</p>
</section>
</div> </div>
</div>
<ul>
<li><strong>Բանկային քարտեր՝</strong> Visa, Mastercard, ՄԻՐ</li>
<li><strong>Արագ վճարման համակարգ (ՍԲՊ)՝</strong> ակնթարթ փոխանցում բանկի բջջային հավելվածի միջոցով</li>
<li><strong>Էլեկտրոնային դրամապանակներ՝</strong> ՅուMoney, QIWI (առկայության դեպքում)</li>
<li><strong>Վճարում հղման միջոցով՝</strong> յուրաքանչյուր պատվերի համար եզակի վճարման հղման ստեղծում</li>
</ul>
<p>2.2. Հասանելի վճարման եղանակները կարող են տարբերվել կախված Վաճառողից և Ապրանքի/Ծառայության տեսակից։</p>
<p>2.3. Բոլոր վճարումները մշակվում են սերտիֆիկացված վճարային համակարգերի միջոցով՝ PCI DSS անվտանգության ստանդարտներին համապատասխան։</p>
</section>
<section class="legal-section">
<h2>3. Վճարման գործընթացը</h2>
<p>3.1. Պատվերի վճարման գործընթացը ներառում է հետևյալ քայլերը՝</p>
<ol>
<li>Ապրանքների/Ծառայությունների ընտրություն և դրանց ավելացնել զամբյուղի մեջ</li>
<li>Պատվերի ձևակերպում՝ կապի տվյալների և առաքման եղանակի նշումով (անհրաժեշտության դեպքում)</li>
<li>Վճարման եղանակի ընտրություն հասանելի տարբերակներից</li>
<li>Վերահղորդում վճարային համակարգի պաշտպանված էջ կամ վճարման հղման ստացում</li>
<li>Վճարման տվյալների մուտքագրում և վճարման հաստատում</li>
<li>Հաջող վճարման մասին ծանուցման ստացում</li>
</ol>
<p>3.2. Բանկային քարտով վճարելիս Գնորդը կարող է վերահղորդվել թողարկող բանկի էջ՝ լրացուցիչ նույնականացման համար (3D-Secure)։</p>
<p>3.3. Գնորդի վճարման պարտավորությունը համարվում է կատարված վճարային համակարգի հաշվին դրամական միջոցների մուտքի պահից։</p>
</section>
<section class="legal-section">
<h2>4. Վճարումների անվտանգություն</h2>
<p>4.1. Բոլոր վճարումները մշակվում են պաշտպանված HTTPS կապի միջոցով՝ TLS 1.2 և ավելի բարձր պրոտոկոլի օգտագործմամբ։</p>
<p>4.2. Մարկետփլեյսը չի պահպանում Գնորդների բանկային քարտերի լիարժեկ տվյալները։ Վճարային տվյալների մշակումը կատարվում է սերտիֆիկացված վճարային ագրեգատորների կողմից։</p>
<p>4.3. Խարդախությունից պաշտպանության համար կիրառվում է 3D-Secure տեխնոլոգիան՝ վճարումը հաստատելու համար SMS կոդի կամ բանկի push ծանուցման միջոցով։</p>
<p>4.4. Կասկածելի գործողության դեպքում վճարային համակարգը իրավունք ունի պահանջելու Գնորդի ինքնության լրացուցիչ ստուգում։</p>
</section>
<section class="legal-section">
<h2>5. Վճարման հաստատում</h2>
<p>5.1. Հաջող վճարմանից հետո Գնորդը ստանում է հաստատում պատվերի ձևակերպման ժամանակ նշված էլեկտրոնային փոստի հասցեին։</p>
<p>5.2. Հաստատումը պարունակում է հետևյալ տեղեկատվությունը՝</p>
<ul>
<li>Պատվերի համարը</li>
<li>Վճարման ամսաթիվը և ժամը</li>
<li>Վճարման գումարը</li>
<li>Պատվերի բովանդակությունը</li>
<li>Վաճառողի կապի տվյալները</li>
</ul>
<p>5.3. Պատվերի մասին տեղեկատվությունը նաև ցուցադրվում է Գնորդի անձնական հաշվից Մարկետփլեյսում (գրանցման դեպքում)։</p>
<p>5.4. Ֆիսկալ կտրոնը ուղարկվում է Վաճառողի կողմից՝ ՌՀ օրենսդրության պահանջներին համապատասխան։</p>
</section>
<section class="legal-section">
<h2>6. Միջոցների վերադարձ</h2>
<p>6.1. Դրամական միջոցների վերադարձի կարգը կարգավորվում է <a [routerLink]="'/return-policy' | langRoute">Վերադարձի քաղաքականությամբ</a> և կախված է ձեռք բերված Ապրանքի/Ծառայության տեսակից։</p>
<p>6.2. Միջոցների վերադարձը կատարվում է նույն վճարային գործիքին՝ որից կատարվել էր վճարումը։</p>
<p>6.3. Դրամական միջոցների վերադարձի ժամկետները՝</p>
<ul>
<li>Բանկային քարտին՝ 3-ից 30 բանկային օր (կախված թողարկող բանկից)</li>
<li>Էլեկտրոնային դրամապանակին՝ 1-ից 5 աշխատանքային օր</li>
<li>ՍԲՊ-ի միջոցով՝ 1-ից 3 աշխատանքային օր</li>
</ul>
<p>6.4. Վերադարձի մշակման համար Մարկետփլեյսը միջնորդավճար չի գանձում։ Վճարային համակարգերի և բանկերի միջնորդավճարները կարող են կիրառվել իրենց սակագներին համապատասխան։</p>
</section>
<section class="legal-section">
<h2>7. Անհաջող վճարումներ</h2>
<p>7.1. Վճարումը կարող է մերժվել հետևյալ պատճառներով՝</p>
<ul>
<li>Հաշվին անբավարար միջոցներ</li>
<li>Վճարային տվյալները սխալ են մուտքագրված</li>
<li>Քարտը արգելափակված է կամ ժամկետն անցած է</li>
<li>Բանկի կողմից սահմանված գործարքների սահմանափակումները գերազանցված են</li>
<li>Անվտանգության համակարգի կողմից գործարքի մերժում</li>
</ul>
<p>7.2. Անհաջող վճարման դեպքում Գնորդը ստանում է ծանուցում՝ մերժման պատճառի նշումով։</p>
<p>7.3. Վճարման խնդիրների դեպքում խորհուրդ է տրվում՝</p>
<ul>
<li>Ստուգել մուտքագրված տվյալների ճշտությունը</li>
<li>Կապվել քարտ թողարկող բանկի հետ՝ մերժման պատճառը ճշտելու համար</li>
<li>Փորձել այլընտրանքային վճարման եղանակ</li>
<li>Դիմել աջակցության ծառայությանը՝ <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></li>
</ul>
</section>
<section class="legal-section">
<h2>8. Վճարման հարցերի կապի տեղեկատվություն</h2>
<p>Պատվերների վճարման հետ կապված հարցերի համար կարող եք դիմել՝</p>
<ul>
<li><strong>Էլ. փոստ՝</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></li>
<li><strong>Աշխատանքային ժամեր՝</strong> Շուրջօրյա (տեխնիկական աջակցություն)</li>
<li><strong>Միջին պատասխանի ժամը՝</strong> Մինչև 24 ժամ աշխատանքային օրերին</li>
</ul>
<p>Դիմելիս նշեք պատվերի համարը և խնդրի հակիրճ նկարագրությունը՝ հարցի ավելի արագ լուծման համար։</p>
</section>

View File

@@ -1,113 +1,117 @@
<h1>Правила оплаты</h1> <div class="legal-page">
<div class="legal-container">
<section class="legal-section"> <h1>Правила оплаты</h1>
<h2>1. Общие положения</h2>
<p>1.1. Настоящие Правила определяют порядок оплаты Товаров и Услуг, приобретаемых Покупателями через Маркетплейс DexarMarket.</p> <section class="legal-section">
<p>1.2. Оплата производится за Товары/Услуги, размещенные независимыми Продавцами. Маркетплейс выступает в качестве информационного посредника и обеспечивает техническую инфраструктуру для проведения платежей.</p> <h2>1. Общие положения</h2>
<p>1.3. Оплата товаров и услуг на Маркетплейсе осуществляется в российских рублях (RUB).</p> <p>1.1. Настоящие Правила определяют порядок оплаты Товаров и Услуг, приобретаемых Покупателями через Маркетплейс DexarMarket.</p>
<p>1.4. Цены на Товары/Услуги устанавливаются Продавцами самостоятельно и указываются на странице соответствующего Товара/Услуги.</p> <p>1.2. Оплата производится за Товары/Услуги, размещенные независимыми Продавцами. Маркетплейс выступает в качестве информационного посредника и обеспечивает техническую инфраструктуру для проведения платежей.</p>
</section> <p>1.3. Оплата товаров и услуг на Маркетплейсе осуществляется в российских рублях (RUB).</p>
<p>1.4. Цены на Товары/Услуги устанавливаются Продавцами самостоятельно и указываются на странице соответствующего Товара/Услуги.</p>
<section class="legal-section"> </section>
<h2>2. Способы оплаты</h2>
<p>2.1. Маркетплейс поддерживает следующие способы оплаты:</p> <section class="legal-section">
<h2>2. Способы оплаты</h2>
<div class="payment-methods"> <p>2.1. Маркетплейс поддерживает следующие способы оплаты:</p>
<div class="payment-logos">
<img src="/assets/images/mir-logo.svg" alt="МИР" loading="lazy" width="80" height="50" /> <div class="payment-methods">
<img src="/assets/images/visa-logo.svg" alt="Visa" loading="lazy" width="80" height="50" /> <div class="payment-logos">
<img src="/assets/images/mastercard-logo.svg" alt="Mastercard" loading="lazy" width="80" height="50" /> <img src="/assets/images/mir-logo.svg" alt="МИР" loading="lazy" width="80" height="50" />
</div> <img src="/assets/images/visa-logo.svg" alt="Visa" loading="lazy" width="80" height="50" />
<img src="/assets/images/mastercard-logo.svg" alt="Mastercard" loading="lazy" width="80" height="50" />
</div>
</div>
<ul>
<li><strong>Банковские карты:</strong> Visa, Mastercard, МИР</li>
<li><strong>Системы быстрых платежей (СБП):</strong> мгновенный перевод через мобильное приложение банка</li>
<li><strong>Электронные кошельки:</strong> ЮMoney, QIWI (при наличии)</li>
<li><strong>Оплата по ссылке:</strong> генерация уникальной платежной ссылки для каждого заказа</li>
</ul>
<p>2.2. Доступные способы оплаты могут различаться в зависимости от Продавца и типа Товара/Услуги.</p>
<p>2.3. Все платежи обрабатываются через сертифицированные платежные системы с соблюдением стандартов безопасности PCI DSS.</p>
</section>
<section class="legal-section">
<h2>3. Процесс оплаты</h2>
<p>3.1. Процедура оплаты заказа включает следующие этапы:</p>
<ol>
<li>Выбор Товаров/Услуг и добавление их в корзину</li>
<li>Оформление заказа с указанием контактных данных и способа доставки (при необходимости)</li>
<li>Выбор способа оплаты из доступных вариантов</li>
<li>Перенаправление на защищенную страницу платежной системы или получение платежной ссылки</li>
<li>Ввод платежных данных и подтверждение оплаты</li>
<li>Получение уведомления об успешной оплате</li>
</ol>
<p>3.2. При оплате банковской картой Покупатель может быть перенаправлен на страницу банка-эмитента для прохождения дополнительной аутентификации (3D-Secure).</p>
<p>3.3. Обязательство Покупателя по оплате считается исполненным с момента поступления денежных средств на счет платежной системы.</p>
</section>
<section class="legal-section">
<h2>4. Безопасность платежей</h2>
<p>4.1. Все платежи обрабатываются через защищенное HTTPS-соединение с использованием протокола TLS 1.2 и выше.</p>
<p>4.2. Маркетплейс не хранит полные данные банковских карт Покупателей. Обработка платежных данных осуществляется сертифицированными платежными агрегаторами.</p>
<p>4.3. Для защиты от мошенничества применяется технология 3D-Secure, требующая подтверждения платежа через SMS-код или push-уведомление от банка.</p>
<p>4.4. В случае подозрительной активности платежная система имеет право запросить дополнительную верификацию личности Покупателя.</p>
</section>
<section class="legal-section">
<h2>5. Подтверждение оплаты</h2>
<p>5.1. После успешной оплаты Покупатель получает подтверждение на указанный при оформлении заказа адрес электронной почты.</p>
<p>5.2. Подтверждение содержит следующую информацию:</p>
<ul>
<li>Номер заказа</li>
<li>Дата и время оплаты</li>
<li>Сумма платежа</li>
<li>Состав заказа</li>
<li>Контактные данные Продавца</li>
</ul>
<p>5.3. Информация о заказе также отображается в личном кабинете Покупателя на Маркетплейсе (при наличии регистрации).</p>
<p>5.4. Фискальный чек направляется Продавцом в соответствии с требованиями законодательства РФ.</p>
</section>
<section class="legal-section">
<h2>6. Возврат средств</h2>
<p>6.1. Порядок возврата денежных средств регулируется <a [routerLink]="'/return-policy' | langRoute">Политикой возврата</a> и зависит от типа приобретенного Товара/Услуги.</p>
<p>6.2. Возврат средств производится на тот же платежный инструмент, с которого была произведена оплата.</p>
<p>6.3. Срок возврата денежных средств составляет:</p>
<ul>
<li>На банковскую карту: от 3 до 30 банковских дней (в зависимости от банка-эмитента)</li>
<li>На электронный кошелек: от 1 до 5 рабочих дней</li>
<li>Через СБП: от 1 до 3 рабочих дней</li>
</ul>
<p>6.4. За обработку возврата средств Маркетплейс комиссию не взимает. Комиссии платежных систем и банков могут применяться в соответствии с их тарифами.</p>
</section>
<section class="legal-section">
<h2>7. Неуспешные платежи</h2>
<p>7.1. Платеж может быть отклонен по следующим причинам:</p>
<ul>
<li>Недостаточно средств на счете</li>
<li>Неверно введены платежные данные</li>
<li>Карта заблокирована или просрочена</li>
<li>Превышены лимиты на операции, установленные банком</li>
<li>Отказ в проведении транзакции системой безопасности</li>
</ul>
<p>7.2. В случае неуспешной оплаты Покупатель получает уведомление с указанием причины отказа.</p>
<p>7.3. При возникновении проблем с оплатой рекомендуется:</p>
<ul>
<li>Проверить правильность введенных данных</li>
<li>Связаться с банком-эмитентом карты для уточнения причины отказа</li>
<li>Попробовать альтернативный способ оплаты</li>
<li>Обратиться в службу поддержки: <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></li>
</ul>
</section>
<section class="legal-section">
<h2>8. Контакты для вопросов по оплате</h2>
<p>По вопросам, связанным с оплатой заказов, вы можете обратиться:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></li>
<li><strong>Время работы:</strong> Круглосуточно (техническая поддержка)</li>
<li><strong>Среднее время ответа:</strong> До 24 часов в рабочие дни</li>
</ul>
<p>При обращении указывайте номер заказа и краткое описание проблемы для более быстрого решения вопроса.</p>
</section>
</div> </div>
</div>
<ul>
<li><strong>Банковские карты:</strong> Visa, Mastercard, МИР</li>
<li><strong>Системы быстрых платежей (СБП):</strong> мгновенный перевод через мобильное приложение банка</li>
<li><strong>Электронные кошельки:</strong> ЮMoney, QIWI (при наличии)</li>
<li><strong>Оплата по ссылке:</strong> генерация уникальной платежной ссылки для каждого заказа</li>
</ul>
<p>2.2. Доступные способы оплаты могут различаться в зависимости от Продавца и типа Товара/Услуги.</p>
<p>2.3. Все платежи обрабатываются через сертифицированные платежные системы с соблюдением стандартов безопасности PCI DSS.</p>
</section>
<section class="legal-section">
<h2>3. Процесс оплаты</h2>
<p>3.1. Процедура оплаты заказа включает следующие этапы:</p>
<ol>
<li>Выбор Товаров/Услуг и добавление их в корзину</li>
<li>Оформление заказа с указанием контактных данных и способа доставки (при необходимости)</li>
<li>Выбор способа оплаты из доступных вариантов</li>
<li>Перенаправление на защищенную страницу платежной системы или получение платежной ссылки</li>
<li>Ввод платежных данных и подтверждение оплаты</li>
<li>Получение уведомления об успешной оплате</li>
</ol>
<p>3.2. При оплате банковской картой Покупатель может быть перенаправлен на страницу банка-эмитента для прохождения дополнительной аутентификации (3D-Secure).</p>
<p>3.3. Обязательство Покупателя по оплате считается исполненным с момента поступления денежных средств на счет платежной системы.</p>
</section>
<section class="legal-section">
<h2>4. Безопасность платежей</h2>
<p>4.1. Все платежи обрабатываются через защищенное HTTPS-соединение с использованием протокола TLS 1.2 и выше.</p>
<p>4.2. Маркетплейс не хранит полные данные банковских карт Покупателей. Обработка платежных данных осуществляется сертифицированными платежными агрегаторами.</p>
<p>4.3. Для защиты от мошенничества применяется технология 3D-Secure, требующая подтверждения платежа через SMS-код или push-уведомление от банка.</p>
<p>4.4. В случае подозрительной активности платежная система имеет право запросить дополнительную верификацию личности Покупателя.</p>
</section>
<section class="legal-section">
<h2>5. Подтверждение оплаты</h2>
<p>5.1. После успешной оплаты Покупатель получает подтверждение на указанный при оформлении заказа адрес электронной почты.</p>
<p>5.2. Подтверждение содержит следующую информацию:</p>
<ul>
<li>Номер заказа</li>
<li>Дата и время оплаты</li>
<li>Сумма платежа</li>
<li>Состав заказа</li>
<li>Контактные данные Продавца</li>
</ul>
<p>5.3. Информация о заказе также отображается в личном кабинете Покупателя на Маркетплейсе (при наличии регистрации).</p>
<p>5.4. Фискальный чек направляется Продавцом в соответствии с требованиями законодательства РФ.</p>
</section>
<section class="legal-section">
<h2>6. Возврат средств</h2>
<p>6.1. Порядок возврата денежных средств регулируется <a [routerLink]="'/return-policy' | langRoute">Политикой возврата</a> и зависит от типа приобретенного Товара/Услуги.</p>
<p>6.2. Возврат средств производится на тот же платежный инструмент, с которого была произведена оплата.</p>
<p>6.3. Срок возврата денежных средств составляет:</p>
<ul>
<li>На банковскую карту: от 3 до 30 банковских дней (в зависимости от банка-эмитента)</li>
<li>На электронный кошелек: от 1 до 5 рабочих дней</li>
<li>Через СБП: от 1 до 3 рабочих дней</li>
</ul>
<p>6.4. За обработку возврата средств Маркетплейс комиссию не взимает. Комиссии платежных систем и банков могут применяться в соответствии с их тарифами.</p>
</section>
<section class="legal-section">
<h2>7. Неуспешные платежи</h2>
<p>7.1. Платеж может быть отклонен по следующим причинам:</p>
<ul>
<li>Недостаточно средств на счете</li>
<li>Неверно введены платежные данные</li>
<li>Карта заблокирована или просрочена</li>
<li>Превышены лимиты на операции, установленные банком</li>
<li>Отказ в проведении транзакции системой безопасности</li>
</ul>
<p>7.2. В случае неуспешной оплаты Покупатель получает уведомление с указанием причины отказа.</p>
<p>7.3. При возникновении проблем с оплатой рекомендуется:</p>
<ul>
<li>Проверить правильность введенных данных</li>
<li>Связаться с банком-эмитентом карты для уточнения причины отказа</li>
<li>Попробовать альтернативный способ оплаты</li>
<li>Обратиться в службу поддержки: <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></li>
</ul>
</section>
<section class="legal-section">
<h2>8. Контакты для вопросов по оплате</h2>
<p>По вопросам, связанным с оплатой заказов, вы можете обратиться:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:info@dexarmarket.ru">info&#64;dexarmarket.ru</a></li>
<li><strong>Время работы:</strong> Круглосуточно (техническая поддержка)</li>
<li><strong>Среднее время ответа:</strong> До 24 часов в рабочие дни</li>
</ul>
<p>При обращении указывайте номер заказа и краткое описание проблемы для более быстрого решения вопроса.</p>
</section>

View File

@@ -1,3 +1,5 @@
<div class="legal-page">
<div class="legal-container">
<h1>PERSONAL DATA PROCESSING POLICY</h1> <h1>PERSONAL DATA PROCESSING POLICY</h1>
<section class="legal-section"> <section class="legal-section">
@@ -361,3 +363,5 @@
<p>12.3. If the Operator can reasonably associate the information specified in this section with the personal account of a specific User, then such information may be processed together with the PD and other personal information of such User.</p> <p>12.3. If the Operator can reasonably associate the information specified in this section with the personal account of a specific User, then such information may be processed together with the PD and other personal information of such User.</p>
</section> </section>
</div>
</div>

View File

@@ -1,3 +1,5 @@
<div class="legal-page">
<div class="legal-container">
<h1>PERSONAL DATA PROCESSING POLICY</h1> <h1>PERSONAL DATA PROCESSING POLICY</h1>
<section class="legal-section"> <section class="legal-section">
@@ -361,3 +363,5 @@
<p>12.3. If the Operator can reasonably associate the information specified in this section with the personal account of a specific User, then such information may be processed together with the PD and other personal information of such User.</p> <p>12.3. If the Operator can reasonably associate the information specified in this section with the personal account of a specific User, then such information may be processed together with the PD and other personal information of such User.</p>
</section> </section>
</div>
</div>

View File

@@ -1,3 +1,5 @@
<div class="legal-page">
<div class="legal-container">
<h1>ПОЛИТИКА В ОТНОШЕНИИ ОБРАБОТКИ ПЕРСОНАЛЬНЫХ ДАННЫХ</h1> <h1>ПОЛИТИКА В ОТНОШЕНИИ ОБРАБОТКИ ПЕРСОНАЛЬНЫХ ДАННЫХ</h1>
<section class="legal-section"> <section class="legal-section">
@@ -361,3 +363,5 @@
<p>12.3. Если Оператор может разумно соотнести указанные в настоящем разделе сведения с личным кабинетом конкретного Пользователя, то такие сведения могут обрабатываться совместно с ПДн и иной личной информацией такого Пользователя.</p> <p>12.3. Если Оператор может разумно соотнести указанные в настоящем разделе сведения с личным кабинетом конкретного Пользователя, то такие сведения могут обрабатываться совместно с ПДн и иной личной информацией такого Пользователя.</p>
</section> </section>
</div>
</div>

View File

@@ -1,3 +1,5 @@
<div class="legal-page">
<div class="legal-container">
<h1>PUBLIC OFFER AGREEMENT</h1> <h1>PUBLIC OFFER AGREEMENT</h1>
<section class="legal-section"> <section class="legal-section">
@@ -459,3 +461,5 @@
<p><strong>16.8. Response to Violations</strong></p> <p><strong>16.8. Response to Violations</strong></p>
<p>Non-intervention by the Site Owner in the event of violations of agreements by Users does not prevent subsequent measures to protect the Owner's interests at a later date.</p> <p>Non-intervention by the Site Owner in the event of violations of agreements by Users does not prevent subsequent measures to protect the Owner's interests at a later date.</p>
</section> </section>
</div>
</div>

View File

@@ -1 +1,5 @@
<h1>Հdelays DELAYS ՀԱՄDELAYS</h1> <div class="legal-page">
<div class="legal-container">
<h1>Հdelays DELAYS ՀԱՄDELAYS</h1>
</div>
</div>

View File

@@ -1,3 +1,5 @@
<div class="legal-page">
<div class="legal-container">
<h1>СОГЛАШЕНИЕ ПУБЛИЧНОЙ ОФЕРТЫ</h1> <h1>СОГЛАШЕНИЕ ПУБЛИЧНОЙ ОФЕРТЫ</h1>
<section class="legal-section"> <section class="legal-section">
@@ -459,3 +461,5 @@
<p><strong>16.8. Реакция на нарушения</strong></p> <p><strong>16.8. Реакция на нарушения</strong></p>
<p>Невмешательство Владельца сайта в случае нарушений соглашений Пользователями не препятствует последующим мерам защиты интересов Владельца позже.</p> <p>Невмешательство Владельца сайта в случае нарушений соглашений Пользователями не препятствует последующим мерам защиты интересов Владельца позже.</p>
</section> </section>
</div>
</div>

View File

@@ -1,3 +1,5 @@
<div class="legal-page">
<div class="legal-container">
<h1>Return Policy</h1> <h1>Return Policy</h1>
<section class="legal-section"> <section class="legal-section">
@@ -128,3 +130,5 @@
</ul> </ul>
<p>If the conflict cannot be resolved amicably, the Buyer has the right to file a complaint with Rospotrebnadzor or the court at the Seller's location.</p> <p>If the conflict cannot be resolved amicably, the Buyer has the right to file a complaint with Rospotrebnadzor or the court at the Seller's location.</p>
</section> </section>
</div>
</div>

View File

@@ -1,3 +1,5 @@
<div class="legal-page">
<div class="legal-container">
<h1>Ապրանքների վերադարձի քաղաքականություն</h1> <h1>Ապրանքների վերադարձի քաղաքականություն</h1>
<section class="legal-section"> <section class="legal-section">
@@ -127,4 +129,6 @@
<li><strong>Անհրաժեշտ նյութեր՝</strong> նամակների պատճեններ՝ ապրանքի լուսանկարներ՝ վճարման կտրոն։</li> <li><strong>Անհրաժեշտ նյութեր՝</strong> նամակների պատճեններ՝ ապրանքի լուսանկարներ՝ վճարման կտրոն։</li>
</ul> </ul>
<p>Եթե կոնֆլիկտը հնարավոր չէ լուծել խաղաղ ճանապարհով՝ Գնորդը իրավունք ունի բողոք ներկայացնելու Ռոսպոտրեբնաձոր կամ դատարան Վաճառողի գտնվելու վայրում։</p> <p>Եթե կոնֆլիկտը հնարավոր չէ լուծել խաղաղ ճանապարհով՝ Գնորդը իրավունք ունի բողոք ներկայացնելու Ռոսպոտրեբնաձոր կամ դատարան Վաճառողի գտնվելու վայրում։</p>
</section> </section>
</div>
</div>

View File

@@ -1,3 +1,5 @@
<div class="legal-page">
<div class="legal-container">
<h1>Политика возврата товаров</h1> <h1>Политика возврата товаров</h1>
<section class="legal-section"> <section class="legal-section">
@@ -128,3 +130,5 @@
</ul> </ul>
<p>Если конфликт невозможно разрешить мирно, Покупатель вправе подать жалобу в Роспотребнадзор или суд по месту расположения Продавца.</p> <p>Если конфликт невозможно разрешить мирно, Покупатель вправе подать жалобу в Роспотребнадзор или суд по месту расположения Продавца.</p>
</section> </section>
</div>
</div>

View File

@@ -56,17 +56,28 @@
@if (items().length > 0) { @if (items().length > 0) {
<div class="items-grid"> <div class="items-grid">
@for (item of items(); track trackByItemId($index, item)) { @for (item of items(); track trackByItemId($index, item)) {
<div class="item-card"> <div class="item-card" (mouseenter)="onItemHover(item.itemID)">
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link"> <a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
<div class="item-image"> <div class="item-image">
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" width="300" height="300" /> <img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
@if (item.discount > 0) { @if (item.discount > 0) {
<div class="discount-badge">-{{ item.discount }}%</div> <div class="discount-badge">-{{ item.discount }}%</div>
} }
@if (item.badges && item.badges.length > 0) {
<div class="item-badges-overlay">
@for (badge of item.badges; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
</div> </div>
<div class="item-details"> <div class="item-details">
<h3 class="item-name">{{ item.name }}</h3> <h3 class="item-name">{{ itemName(item) }}</h3>
@if (itemDesc(item)) {
<p class="item-simple-desc">{{ itemDesc(item) }}</p>
}
<div class="item-rating"> <div class="item-rating">
<span class="rating-stars">⭐ {{ item.rating }}</span> <span class="rating-stars">⭐ {{ item.rating }}</span>
@@ -94,19 +105,29 @@
</div> </div>
</a> </a>
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)"> <button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)" [attr.aria-label]="('search.addToCart' | translate) + ': ' + item.name">
{{ 'search.addToCart' | translate }} {{ 'search.addToCart' | translate }}
</button> </button>
</div> </div>
} }
</div> </div>
@if (loading() && items().length > 0) { @if (loading() && items().length > 0) {
<div class="loading-more"> @for (i of skeletonSlots; track i) {
<div class="spinner"></div> <div class="item-card skeleton-card">
<p>{{ 'search.loadingMore' | translate }}</p> <div class="item-link">
</div> <div class="item-image skeleton-image"></div>
} <div class="item-details">
<div class="skeleton-line skeleton-title"></div>
<div class="skeleton-line skeleton-rating"></div>
<div class="skeleton-line skeleton-price"></div>
<div class="skeleton-line skeleton-stock"></div>
</div>
</div>
<div class="skeleton-btn"></div>
</div>
}
}
@if (!hasMore() && items().length > 0) { @if (!hasMore() && items().length > 0) {
<div class="no-more"> <div class="no-more">

View File

@@ -344,6 +344,59 @@
text-align: center; text-align: center;
} }
// Skeleton loading cards
.skeleton-card {
pointer-events: none;
.skeleton-image {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line {
border-radius: 6px;
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-title {
height: 16px;
width: 80%;
}
.skeleton-rating {
height: 12px;
width: 50%;
}
.skeleton-price {
height: 18px;
width: 40%;
margin-top: auto;
}
.skeleton-stock {
height: 6px;
width: 60px;
}
.skeleton-btn {
height: 42px;
background: linear-gradient(90deg, #5a8a85 25%, #497671 50%, #5a8a85 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0 0 13px 13px;
margin-top: -1px;
}
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (max-width: 768px) { @media (max-width: 768px) {
.search-header h1 { .search-header h1 {
font-size: 1.5rem; font-size: 1.5rem;

View File

@@ -3,13 +3,16 @@ import { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { ApiService, CartService } from '../../services'; import { ApiService, CartService } from '../../services';
import { PrefetchService } from '../../services/prefetch.service';
import { Item } from '../../models'; import { Item } from '../../models';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils'; import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
import { LanguageService } from '../../services/language.service';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service'; import { TranslateService } from '../../i18n/translate.service';
import { SEARCH_DEBOUNCE_MS, ITEMS_PER_PAGE, SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS } from '../../config/constants';
@Component({ @Component({
selector: 'app-search', selector: 'app-search',
@@ -27,7 +30,7 @@ export class SearchComponent implements OnDestroy {
totalResults = signal<number>(0); totalResults = signal<number>(0);
private skip = 0; private skip = 0;
private readonly count = 20; private readonly count = ITEMS_PER_PAGE;
private isLoadingMore = false; private isLoadingMore = false;
private searchSubject = new Subject<string>(); private searchSubject = new Subject<string>();
private searchSubscription: Subscription; private searchSubscription: Subscription;
@@ -35,11 +38,12 @@ export class SearchComponent implements OnDestroy {
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
private cartService: CartService private cartService: CartService,
private prefetchService: PrefetchService
) { ) {
this.searchSubscription = this.searchSubject this.searchSubscription = this.searchSubject
.pipe( .pipe(
debounceTime(300), debounceTime(SEARCH_DEBOUNCE_MS),
distinctUntilChanged() distinctUntilChanged()
) )
.subscribe(query => { .subscribe(query => {
@@ -63,7 +67,7 @@ export class SearchComponent implements OnDestroy {
performSearch(query: string): void { performSearch(query: string): void {
if (!query.trim()) { if (!query.trim()) {
this.items.set([]); this.items.set([]);
this.hasMore.set(true); this.hasMore.set(false);
this.totalResults.set(0); this.totalResults.set(0);
return; return;
} }
@@ -119,12 +123,12 @@ export class SearchComponent implements OnDestroy {
this.scrollTimeout = setTimeout(() => { this.scrollTimeout = setTimeout(() => {
const scrollPosition = window.innerHeight + window.scrollY; const scrollPosition = window.innerHeight + window.scrollY;
const bottomPosition = document.documentElement.scrollHeight - 500; const bottomPosition = document.documentElement.scrollHeight - SCROLL_THRESHOLD_PX;
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore()) { if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore()) {
this.loadResults(); this.loadResults();
} }
}, 100); }, SCROLL_DEBOUNCE_MS);
} }
addToCart(itemID: number, event: Event): void { addToCart(itemID: number, event: Event): void {
@@ -133,7 +137,17 @@ export class SearchComponent implements OnDestroy {
this.cartService.addItem(itemID); this.cartService.addItem(itemID);
} }
onItemHover(itemID: number): void {
this.prefetchService.prefetchItem(itemID);
}
readonly skeletonSlots = Array.from({ length: 8 });
readonly getDiscountedPrice = getDiscountedPrice; readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage; readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId; readonly trackByItemId = trackByItemId;
readonly getBadgeClass = getBadgeClass;
private langService = inject(LanguageService);
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); }
} }

View File

@@ -7,19 +7,30 @@ import { LanguageService } from '../services/language.service';
}) })
export class LangRoutePipe implements PipeTransform { export class LangRoutePipe implements PipeTransform {
private langService = inject(LanguageService); private langService = inject(LanguageService);
private lastLang = '';
private lastInput: unknown = null;
private lastResult: string | (string | number)[] = '';
transform(value: string | (string | number)[]): string | (string | number)[] { transform(value: string | (string | number)[]): string | (string | number)[] {
const lang = this.langService.currentLanguage(); const lang = this.langService.currentLanguage();
// Short-circuit if nothing changed
if (lang === this.lastLang && value === this.lastInput) {
return this.lastResult;
}
this.lastLang = lang;
this.lastInput = value;
if (typeof value === 'string') { if (typeof value === 'string') {
return value === '/' ? `/${lang}` : `/${lang}${value}`; this.lastResult = value === '/' ? `/${lang}` : `/${lang}${value}`;
} } else if (Array.isArray(value) && value.length > 0) {
if (Array.isArray(value) && value.length > 0) {
const [first, ...rest] = value; const [first, ...rest] = value;
return [`/${lang}${first}`, ...rest]; this.lastResult = [`/${lang}${first}`, ...rest];
} else {
this.lastResult = value;
} }
return value; return this.lastResult;
} }
} }

View File

@@ -1,72 +1,318 @@
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable, timer } from 'rxjs';
import { map } from 'rxjs/operators'; import { map, retry } from 'rxjs/operators';
import { Category, Item } from '../models'; import { Category, Item, Subcategory } from '../models';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { LocationService } from './location.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class ApiService { export class ApiService {
private readonly baseUrl = environment.apiUrl; private readonly baseUrl = environment.apiUrl;
private locationService = inject(LocationService);
private readonly retryConfig = {
count: 2,
delay: (error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
};
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
private normalizeItem(item: Item): Item { /** Map API language codes (RU/EN/AM) → frontend codes (ru/en/hy) */
return { private normalizeLang(apiLang: string): string {
...item, const map: Record<string, string> = { 'RU': 'ru', 'EN': 'en', 'AM': 'hy' };
remainings: item.remainings || 'high' return map[apiLang] || apiLang.toLowerCase();
};
} }
private normalizeItems(items: Item[] | null | undefined): Item[] { /** Convert Go-style hex colour (0xfffca0) → CSS hex (#fffca0) */
private normalizeColor(c: string): string {
if (!c) return '';
return c.startsWith('0x') ? '#' + c.slice(2) : c;
}
/** Resolve relative image URLs (e.g. ./images/x.webp) against site origin */
private resolveImageUrl(url: string): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return url;
const origin = `https://${environment.domain}`;
if (url.startsWith('./')) return `${origin}/${url.slice(2)}`;
return `${origin}/${url}`;
}
/**
* Normalize an item from the API response — supports both
* legacy marketplace format and the new backOffice API format.
*/
private normalizeItem(raw: any): Item {
const { partnerID, ...rest } = raw;
const item: Item = { ...rest };
// Extract price/currency/remaining/colour/size from itemDetails[]
// Note: Go struct tag is "itemdetails" but actual API may send "itemDetails"
const details = raw.itemDetails || raw.itemdetails;
if (details && Array.isArray(details) && details.length > 0) {
const detail = details[0];
item.itemDetails = details.map((d: any) => ({
...d,
colour: this.normalizeColor(d.colour || d.color || ''),
color: undefined,
}));
if (item.price == null || item.price === 0) item.price = detail.price;
if (!item.currency) item.currency = detail.currency;
if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || '');
if (!item.size) item.size = detail.size || '';
// Use remaining from detail for stock level
if (raw.remaining == null && detail.remaining != null) {
(raw as any).remaining = detail.remaining;
}
}
// Map backOffice string id → legacy numeric itemID
if (raw.id != null && raw.itemID == null) {
item.id = String(raw.id);
item.itemID = typeof raw.id === 'number' ? raw.id : 0;
}
// Map backOffice imgs[] → legacy photos[]
if (raw.imgs && (!raw.photos || raw.photos.length === 0)) {
item.photos = raw.imgs.map((url: string) => ({ url }));
}
// Normalize photo type: API sends type='video'|'photo', template checks .video
// Also resolve relative URLs (e.g. ./images/x.webp) against API base
if (item.photos) {
item.photos = item.photos.map((p: any) => ({
...p,
url: this.resolveImageUrl(p.url),
video: p.video || (p.type === 'video' ? p.url : undefined),
}));
}
item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u))
|| item.photos?.map((p: any) => p.url) || [];
// Map backOffice description (key-value array) → legacy description string
if (Array.isArray(raw.description)) {
item.descriptionFields = raw.description;
item.description = raw.description.map((d: any) => `${d.key}: ${d.value}`).join('\n');
} else {
item.description = raw.description || raw.simpleDescription || '';
}
// Map backend names[] → translations (multi-lang name support)
// Note: API has typo "valuue" in some responses, handle both
if (raw.names && Array.isArray(raw.names)) {
item.names = raw.names;
if (!item.translations) item.translations = {};
for (const entry of raw.names) {
const lang = this.normalizeLang(entry.language);
const val = entry.value || entry.valuue || '';
if (val) {
if (!item.translations[lang]) item.translations[lang] = {};
item.translations[lang].name = val;
}
}
// Fallback: if top-level name is missing, use first available translation
if (!item.name && raw.names.length > 0) {
const ruName = raw.names.find((n: any) => n.language === 'RU' || n.language === 'ru');
item.name = ruName?.value || ruName?.valuue || raw.names[0].value || raw.names[0].valuue || '';
}
}
// Preserve attributes from backend
item.attributes = raw.attributes || [];
// Preserve colour & size (only if not already set from itemDetails)
if (!item.colour) item.colour = this.normalizeColor(raw.colour || '');
if (!item.size) item.size = raw.size || '';
// Map backOffice comments → legacy callbacks
if (raw.comments && (!raw.callbacks || raw.callbacks.length === 0)) {
item.callbacks = raw.comments.map((c: any) => ({
rating: c.stars,
content: c.text,
userID: c.author,
timestamp: c.createdAt,
}));
}
item.comments = raw.comments || raw.callbacks?.map((c: any) => ({
id: c.userID,
text: c.content,
author: c.userID,
stars: c.rating,
createdAt: c.timestamp,
})) || [];
// Compute average rating from comments if not present
if (raw.rating == null && item.comments && item.comments.length > 0) {
const rated = item.comments.filter(c => c.stars != null);
item.rating = rated.length > 0
? rated.reduce((sum, c) => sum + (c.stars || 0), 0) / rated.length
: 0;
}
item.rating = item.rating || 0;
// Defaults
item.name = item.name || '';
item.price = item.price ?? 0;
item.discount = item.discount || 0;
item.remainings = item.remainings || (raw.remaining != null
? (raw.remaining <= 0 ? 'out' : raw.remaining <= 5 ? 'low' : raw.remaining <= 20 ? 'medium' : 'high')
: raw.quantity != null
? (raw.quantity <= 0 ? 'out' : raw.quantity <= 5 ? 'low' : raw.quantity <= 20 ? 'medium' : 'high')
: 'high');
item.currency = item.currency || 'RUB';
// Preserve new backOffice fields
item.badges = raw.badges || [];
item.tags = raw.tags || [];
item.simpleDescription = raw.simpleDescription || '';
item.translations = item.translations || raw.translations || {};
item.visible = raw.visible ?? true;
item.priority = raw.priority ?? 0;
item.visits = raw.visits ?? 0;
// Map question like/dislike → upvotes/downvotes
if (item.questions) {
item.questions = item.questions.map((q: any) => ({
...q,
upvotes: q.upvotes ?? q.like ?? 0,
downvotes: q.downvotes ?? q.dislike ?? 0,
}));
}
return item;
}
private normalizeItems(items: any[] | null | undefined): Item[] {
if (!items || !Array.isArray(items)) { if (!items || !Array.isArray(items)) {
return []; return [];
} }
return items.map(item => this.normalizeItem(item)); return items.map(item => this.normalizeItem(item));
} }
/** Append region query param if a region is selected */ /**
private withRegion(params: HttpParams = new HttpParams()): HttpParams { * Normalize a category from the API response — supports both
const regionId = this.locationService.regionId(); * the flat legacy format and nested backOffice format.
return regionId ? params.set('region', regionId) : params; */
private normalizeCategory(raw: any): Category {
const cat: Category = { ...raw };
if (raw.id != null && raw.categoryID == null) {
cat.id = String(raw.id);
cat.categoryID = typeof raw.id === 'number' ? raw.id : 0;
}
// Map backOffice img → legacy icon
if (raw.img && !raw.icon) {
cat.icon = raw.img;
}
cat.img = raw.img || raw.icon;
// Resolve relative icon/image URLs
if (cat.icon) cat.icon = this.resolveImageUrl(cat.icon);
if (cat.img) cat.img = this.resolveImageUrl(cat.img);
// Map backend wideicon → wideBanner
if (raw.wideicon && !cat.wideBanner) {
cat.wideBanner = raw.wideicon;
}
cat.parentID = raw.parentID ?? 0;
cat.visible = raw.visible ?? true;
cat.priority = raw.priority ?? 0;
cat.itemCount = raw.itemCount ?? raw.ItemsCount ?? 0;
cat.categoriesCount = raw.categoriesCount ?? raw.CategoriesCount ?? 0;
// Map backend names[] → translations (multi-lang name support)
// Note: API has typo "valuue" in some responses, handle both
if (raw.names && Array.isArray(raw.names)) {
cat.names = raw.names;
cat.translations = cat.translations || {};
for (const entry of raw.names) {
const lang = this.normalizeLang(entry.language);
const val = entry.value || entry.valuue || '';
if (val) {
if (!cat.translations[lang]) cat.translations[lang] = {};
cat.translations[lang].name = val;
}
}
// Fallback: if top-level name is missing, use first available translation
if (!cat.name && raw.names.length > 0) {
const ruName = raw.names.find((n: any) => n.language === 'RU' || n.language === 'ru');
cat.name = ruName?.value || ruName?.valuue || raw.names[0].value || raw.names[0].valuue || '';
}
}
cat.name = cat.name || '';
if (raw.subcategories && Array.isArray(raw.subcategories)) {
cat.subcategories = raw.subcategories;
}
return cat;
} }
private normalizeCategories(cats: any[] | null | undefined): Category[] {
if (!cats || !Array.isArray(cats)) return [];
return cats.map(c => this.normalizeCategory(c));
}
// ─── Core Marketplace Endpoints ───────────────────────────
ping(): Observable<{ message: string }> { ping(): Observable<{ message: string }> {
return this.http.get<{ message: string }>(`${this.baseUrl}/ping`); return this.http.get<{ message: string }>(`${this.baseUrl}/ping`);
} }
getCategories(): Observable<Category[]> { getCategories(): Observable<Category[]> {
return this.http.get<Category[]>(`${this.baseUrl}/category`, { params: this.withRegion() }); return this.http.get<any[]>(`${this.baseUrl}/category`)
.pipe(retry(this.retryConfig), map(cats => this.normalizeCategories(cats)));
} }
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> { getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
const params = this.withRegion( const params = new HttpParams()
new HttpParams() .set('count', count.toString())
.set('count', count.toString()) .set('skip', skip.toString());
.set('skip', skip.toString()) return this.http.get<any[]>(`${this.baseUrl}/category/${categoryID}`, { params })
); .pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
return this.http.get<Item[]>(`${this.baseUrl}/category/${categoryID}`, { params })
.pipe(map(items => this.normalizeItems(items)));
} }
getItem(itemID: number): Observable<Item> { getItem(itemID: number): Observable<Item> {
return this.http.get<Item>(`${this.baseUrl}/item/${itemID}`, { params: this.withRegion() }) return this.http.get<any>(`${this.baseUrl}/items/${itemID}`)
.pipe(map(item => this.normalizeItem(item))); .pipe(retry(this.retryConfig), map(item => this.normalizeItem(item)));
} }
searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> { searchItems(
const params = this.withRegion( search: string,
new HttpParams() count: number = 50,
.set('search', search) skip: number = 0,
.set('count', count.toString()) options?: {
.set('skip', skip.toString()) categoryIDs?: number[];
); minPrice?: number;
return this.http.get<{ items: Item[], total: number, count: number, skip: number }>(`${this.baseUrl}/searchitems`, { params }) maxPrice?: number;
tag?: string;
sort?: 'relevance' | 'price_asc' | 'price_desc' | 'popular' | 'rating';
}
): Observable<{ items: Item[], total: number }> {
let params = new HttpParams()
.set('search', search)
.set('count', count.toString())
.set('skip', skip.toString());
if (options?.categoryIDs?.length) {
params = params.set('categoryIDs', options.categoryIDs.join(','));
}
if (options?.minPrice != null) {
params = params.set('minPrice', options.minPrice.toString());
}
if (options?.maxPrice != null) {
params = params.set('maxPrice', options.maxPrice.toString());
}
if (options?.tag) {
params = params.set('tag', options.tag);
}
if (options?.sort) {
params = params.set('sort', options.sort);
}
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
.pipe( .pipe(
retry(this.retryConfig),
map(response => ({ map(response => ({
items: this.normalizeItems(response?.items || []), items: this.normalizeItems(response?.items || []),
total: response?.total || 0 total: response?.total || 0
@@ -74,21 +320,9 @@ export class ApiService {
); );
} }
addToCart(itemID: number, quantity: number = 1): Observable<{ message: string }> { // Cart operations — spec uses websession-based paths
return this.http.post<{ message: string }>(`${this.baseUrl}/cart`, { itemID, quantity }); addToCart(sessionId: string, items: Array<{ itemID: number; quantity: number; colour?: string; size?: string; price?: number }>): Observable<any> {
} return this.http.post<any>(`${this.baseUrl}/websession/${sessionId}`, items);
updateCartQuantity(itemID: number, quantity: number): Observable<{ message: string }> {
return this.http.patch<{ message: string }>(`${this.baseUrl}/cart`, { itemID, quantity });
}
removeFromCart(itemIDs: number[]): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.baseUrl}/cart`, { body: itemIDs });
}
getCart(): Observable<Item[]> {
return this.http.get<Item[]>(`${this.baseUrl}/cart`)
.pipe(map(items => this.normalizeItems(items)));
} }
// Review submission // Review submission
@@ -96,39 +330,42 @@ export class ApiService {
itemID: number; itemID: number;
rating: number; rating: number;
comment: string; comment: string;
username: string | null; sessionID: string;
userId: number | null;
timestamp: string; timestamp: string;
}): Observable<{ message: string }> { }): Observable<{ message: string }> {
return this.http.post<{ message: string }>(`${this.baseUrl}/comment`, reviewData); const { itemID, ...body } = reviewData;
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/callback`, body);
} }
// Payment - SBP Integration // Question submission — spec path has typo "questiion"
createPayment(paymentData: { submitQuestion(questionData: {
amount: number; itemID: number;
currency: string; question: string;
siteuserID: string; sessionID: string;
siteorderID: string; timestamp: string;
redirectUrl: string; }): Observable<{ message: string }> {
telegramUsername: string; const { itemID, ...body } = questionData;
items: Array<{ itemID: number; price: number; name: string }>; return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/questiion`, body);
}): Observable<{ }
// Payment - SBP Integration via websession QR
createPayment(sessionId: string): Observable<{
qrId: string; qrId: string;
qrStatus: string; qrStatus: string;
qrExpirationDate: string; qrExpirationDate: string;
payload: string; Payload: string;
qrUrl: string; qrUrl: string;
}> { }> {
return this.http.post<{ return this.http.post<{
qrId: string; qrId: string;
qrStatus: string; qrStatus: string;
qrExpirationDate: string; qrExpirationDate: string;
payload: string; Payload: string;
qrUrl: string; qrUrl: string;
}>(`${this.baseUrl}/cart`, paymentData); }>(`${this.baseUrl}/websession/${sessionId}/qr`, {});
} }
checkPaymentStatus(qrId: string): Observable<{ checkPaymentStatus(sessionId: string, qrId: string): Observable<{
additionalInfo: string; additionalInfo: string;
paymentPurpose: string; paymentPurpose: string;
amount: number; amount: number;
@@ -141,7 +378,6 @@ export class ApiService {
transactionDate: string; transactionDate: string;
transactionId: number; transactionId: number;
qrExpirationDate: string; qrExpirationDate: string;
phoneNumber: string;
}> { }> {
return this.http.get<{ return this.http.get<{
additionalInfo: string; additionalInfo: string;
@@ -156,8 +392,7 @@ export class ApiService {
transactionDate: string; transactionDate: string;
transactionId: number; transactionId: number;
qrExpirationDate: string; qrExpirationDate: string;
phoneNumber: string; }>(`${this.baseUrl}/websession/${sessionId}/${qrId}`);
}>(`${this.baseUrl}/qr/payment/${qrId}`);
} }
submitPurchaseEmail(emailData: { submitPurchaseEmail(emailData: {
@@ -169,11 +404,11 @@ export class ApiService {
} }
getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> { getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> {
let params = this.withRegion(new HttpParams().set('count', count.toString())); let params = new HttpParams().set('count', count.toString());
if (categoryID) { if (categoryID) {
params = params.set('category', categoryID.toString()); params = params.set('category', categoryID.toString());
} }
return this.http.get<Item[]>(`${this.baseUrl}/randomitems`, { params }) return this.http.get<any[]>(`${this.baseUrl}/items/randomitems`, { params })
.pipe(map(items => this.normalizeItems(items))); .pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
} }
} }

View File

@@ -72,7 +72,7 @@ export class AuthService {
/** Generate the Telegram login URL for bot-based auth */ /** Generate the Telegram login URL for bot-based auth */
getTelegramLoginUrl(): string { getTelegramLoginUrl(): string {
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'dexarmarket_bot'; const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`); const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`);
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`; return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
} }

View File

@@ -13,6 +13,7 @@ export class CartService {
private cartItems = signal<CartItem[]>([]); private cartItems = signal<CartItem[]>([]);
private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp; private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp;
private addingItems = new Set<number>(); private addingItems = new Set<number>();
private initialized = false;
items = this.cartItems.asReadonly(); items = this.cartItems.asReadonly();
itemCount = computed(() => { itemCount = computed(() => {
@@ -31,10 +32,12 @@ export class CartService {
constructor(private apiService: ApiService) { constructor(private apiService: ApiService) {
this.loadCart(); this.loadCart();
// Auto-save whenever cart changes // Auto-save whenever cart changes (skip the initial empty state)
effect(() => { effect(() => {
const items = this.cartItems(); const items = this.cartItems();
this.saveToStorage(items); if (this.initialized) {
this.saveToStorage(items);
}
}); });
} }
@@ -67,9 +70,11 @@ export class CartService {
// No data in CloudStorage, try localStorage // No data in CloudStorage, try localStorage
this.loadFromLocalStorage(); this.loadFromLocalStorage();
} }
this.initialized = true;
}); });
} else { } else {
this.loadFromLocalStorage(); this.loadFromLocalStorage();
this.initialized = true;
} }
} }
@@ -98,7 +103,7 @@ export class CartService {
return false; return false;
} }
addItem(itemID: number, quantity: number = 1): void { addItem(itemID: number, quantity: number = 1, variant?: { colour?: string; size?: string; price?: number; currency?: string }): void {
// Prevent duplicate API calls for same item // Prevent duplicate API calls for same item
if (this.addingItems.has(itemID)) return; if (this.addingItems.has(itemID)) return;
@@ -113,7 +118,14 @@ export class CartService {
this.addingItems.add(itemID); this.addingItems.add(itemID);
this.apiService.getItem(itemID).subscribe({ this.apiService.getItem(itemID).subscribe({
next: (item) => { next: (item) => {
const cartItem: CartItem = { ...item, quantity }; const cartItem: CartItem = {
...item,
quantity,
...(variant?.colour != null && { colour: variant.colour }),
...(variant?.size != null && { size: variant.size }),
...(variant?.price != null && { price: variant.price }),
...(variant?.currency != null && { currency: variant.currency }),
};
this.cartItems.set([...this.cartItems(), cartItem]); this.cartItems.set([...this.cartItems(), cartItem]);
this.addingItems.delete(itemID); this.addingItems.delete(itemID);
}, },

View File

@@ -9,19 +9,34 @@ export interface Language {
enabled: boolean; enabled: boolean;
} }
export interface Currency {
code: string;
symbol: string;
name: string;
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class LanguageService { export class LanguageService {
private currentLanguageSignal = signal<string>('ru'); private currentLanguageSignal = signal<string>('ru');
private currentCurrencySignal = signal<string>('RUB');
languages: Language[] = [ languages: Language[] = [
{ code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true }, { code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true },
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: true }, { code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: false },
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: true } { code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: false }
];
currencies: Currency[] = [
{ code: 'RUB', symbol: '₽', name: 'Рубль' },
{ code: 'USD', symbol: '$', name: 'Dollar' },
{ code: 'EUR', symbol: '€', name: 'Euro' },
{ code: 'AMD', symbol: '֏', name: 'Դրամ' },
]; ];
currentLanguage = this.currentLanguageSignal.asReadonly(); currentLanguage = this.currentLanguageSignal.asReadonly();
currentCurrency = this.currentCurrencySignal.asReadonly();
constructor(private router: Router) { constructor(private router: Router) {
// Load saved language from localStorage // Load saved language from localStorage
@@ -29,6 +44,11 @@ export class LanguageService {
if (savedLang && this.languages.find(l => l.code === savedLang && l.enabled)) { if (savedLang && this.languages.find(l => l.code === savedLang && l.enabled)) {
this.currentLanguageSignal.set(savedLang); this.currentLanguageSignal.set(savedLang);
} }
const savedCurrency = localStorage.getItem('selectedCurrency');
if (savedCurrency && this.currencies.find(c => c.code === savedCurrency)) {
this.currentCurrencySignal.set(savedCurrency);
}
} }
setLanguage(langCode: string): void { setLanguage(langCode: string): void {
@@ -39,6 +59,18 @@ export class LanguageService {
} }
} }
setCurrency(code: string): void {
const currency = this.currencies.find(c => c.code === code);
if (currency) {
this.currentCurrencySignal.set(code);
localStorage.setItem('selectedCurrency', code);
}
}
getCurrentCurrency(): Currency | undefined {
return this.currencies.find(c => c.code === this.currentCurrencySignal());
}
/** Change language and navigate to the same page with the new prefix */ /** Change language and navigate to the same page with the new prefix */
switchLanguage(langCode: string): void { switchLanguage(langCode: string): void {
const lang = this.languages.find(l => l.code === langCode); const lang = this.languages.find(l => l.code === langCode);

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { ApiService } from './api.service';
@Injectable({ providedIn: 'root' })
export class PrefetchService {
private prefetched = new Set<number>();
constructor(private api: ApiService) {}
prefetchItem(itemID: number): void {
if (this.prefetched.has(itemID)) return;
this.prefetched.add(itemID);
this.api.getItem(itemID).subscribe();
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject, DOCUMENT } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser'; import { Meta, Title } from '@angular/platform-browser';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { Item } from '../models'; import { Item } from '../models';
@@ -10,6 +10,7 @@ import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
export class SeoService { export class SeoService {
private meta = inject(Meta); private meta = inject(Meta);
private title = inject(Title); private title = inject(Title);
private doc = inject(DOCUMENT);
private readonly siteUrl = `https://${environment.domain}`; private readonly siteUrl = `https://${environment.domain}`;
private readonly siteName = environment.brandFullName; private readonly siteName = environment.brandFullName;
@@ -18,13 +19,14 @@ export class SeoService {
* Set Open Graph & Twitter Card meta tags for a product/item page. * Set Open Graph & Twitter Card meta tags for a product/item page.
*/ */
setItemMeta(item: Item): void { setItemMeta(item: Item): void {
const price = item.discount > 0 ? getDiscountedPrice(item) : item.price; const price = item.discount > 0 ? getDiscountedPrice(item) : (item.price ?? 0);
const imageUrl = this.resolveUrl(getMainImage(item)); const imageUrl = this.resolveUrl(getMainImage(item));
const itemUrl = `${this.siteUrl}/item/${item.itemID}`; const itemUrl = `${this.siteUrl}/item/${item.itemID}`;
const description = this.truncate(this.stripHtml(item.description), 160); const description = this.truncate(this.stripHtml(item.description || ''), 160);
const titleText = `${item.name}${this.siteName}`; const titleText = `${item.name || 'Product'}${this.siteName}`;
this.title.setTitle(titleText); this.title.setTitle(titleText);
this.setCanonical(itemUrl);
this.setOrUpdate([ this.setOrUpdate([
// Open Graph // Open Graph
@@ -81,6 +83,7 @@ export class SeoService {
// Remove product-specific tags // Remove product-specific tags
this.meta.removeTag("property='product:price:amount'"); this.meta.removeTag("property='product:price:amount'");
this.meta.removeTag("property='product:price:currency'"); this.meta.removeTag("property='product:price:currency'");
this.removeCanonical();
} }
private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void { private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void {
@@ -114,4 +117,19 @@ export class SeoService {
if (!text || text.length <= maxLength) return text || ''; if (!text || text.length <= maxLength) return text || '';
return text.substring(0, maxLength - 1) + '…'; return text.substring(0, maxLength - 1) + '…';
} }
private setCanonical(url: string): void {
this.removeCanonical();
const link = this.doc.createElement('link');
link.setAttribute('rel', 'canonical');
link.setAttribute('href', url);
this.doc.head.appendChild(link);
}
private removeCanonical(): void {
const existing = this.doc.head.querySelector('link[rel="canonical"]');
if (existing) {
this.doc.head.removeChild(existing);
}
}
} }

View File

@@ -1,13 +1,106 @@
import { Item } from '../models'; import { Item } from '../models';
import { Category } from '../models/category.model';
export function getDiscountedPrice(item: Item): number { export function getDiscountedPrice(item: Item): number {
return item.price * (1 - item.discount / 100); return item.price * (1 - (item.discount || 0) / 100);
} }
export function getMainImage(item: Item): string { export function getMainImage(item: Item): string {
// Support both backOffice format (imgs: string[]) and legacy (photos: Photo[])
if (item.imgs && item.imgs.length > 0) {
return item.imgs[0];
}
return item.photos?.[0]?.url || '/assets/images/placeholder.svg'; return item.photos?.[0]?.url || '/assets/images/placeholder.svg';
} }
export function trackByItemId(index: number, item: Item): number { export function getAllImages(item: Item): string[] {
return item.itemID; if (item.imgs && item.imgs.length > 0) {
return item.imgs;
}
return item.photos?.map(p => p.url) || [];
}
export function trackByItemId(index: number, item: Item): number | string {
return item.id || item.itemID;
}
/**
* Get the display description — supports both legacy HTML string
* and structured key-value pairs from backOffice API.
*/
export function hasStructuredDescription(item: Item): boolean {
return Array.isArray(item.descriptionFields) && item.descriptionFields.length > 0;
}
/**
* Compute stock status from quantity if the legacy `remainings` field is absent.
*/
export function getStockStatus(item: Item): string {
if (item.remainings) return item.remainings;
if (item.quantity == null) return 'high';
if (item.quantity <= 0) return 'out';
if (item.quantity <= 5) return 'low';
if (item.quantity <= 20) return 'medium';
return 'high';
}
/**
* Map backOffice badge names to CSS color classes.
*/
export function getBadgeClass(badge: string): string {
const map: Record<string, string> = {
'new': 'badge-new',
'sale': 'badge-sale',
'exclusive': 'badge-exclusive',
'hot': 'badge-hot',
'limited': 'badge-limited',
'bestseller': 'badge-bestseller',
'featured': 'badge-featured',
};
return map[badge.toLowerCase()] || 'badge-custom';
}
/**
* Get the translated name/description for the current language.
* Checks translations map first, then names[]/descriptions[] arrays,
* then falls back to the default (base) field.
*/
export function getTranslatedField(
item: Item,
field: 'name' | 'simpleDescription',
lang: string
): string {
// 1. Check translations map (already normalized to frontend codes)
const translation = item.translations?.[lang];
if (translation && translation[field]) {
return translation[field]!;
}
// 2. Check names[]/descriptions[] arrays (may have API codes: RU/EN/AM)
// Note: API has typo "valuue" in some responses — handle both
if (field === 'name' && item.names?.length) {
const entry = item.names.find(n => n.language === lang || n.language === lang.toUpperCase() || (lang === 'hy' && n.language === 'AM'));
const val = entry?.value || (entry as any)?.valuue || '';
if (val) return val;
}
// 3. Fallback to base field
if (field === 'name') return item.name;
if (field === 'simpleDescription') return item.simpleDescription || item.description || '';
return '';
}
/**
* Get translated category name for the current language.
*/
export function getTranslatedCategoryName(cat: Category, lang: string): string {
const translation = cat.translations?.[lang];
if (translation?.name) return translation.name;
if (cat.names?.length) {
const entry = cat.names.find(n => n.language === lang || n.language === lang.toUpperCase() || (lang === 'hy' && n.language === 'AM'));
const val = entry?.value || (entry as any)?.valuue || '';
if (val) return val;
}
return cat.name || '';
} }

View File

@@ -14,5 +14,6 @@ export const environment = {
phones: { phones: {
armenia: '+374 98 731231', armenia: '+374 98 731231',
support: '+374 98 731231' support: '+374 98 731231'
} },
useMockData: false
}; };

View File

@@ -14,5 +14,6 @@ export const environment = {
phones: { phones: {
armenia: '+374 98 731231', armenia: '+374 98 731231',
support: '+374 98 731231' support: '+374 98 731231'
} },
useMockData: true
}; };

View File

@@ -10,9 +10,10 @@ export const environment = {
supportEmail: 'info@dexarmarket.ru', supportEmail: 'info@dexarmarket.ru',
domain: 'dexarmarket.ru', domain: 'dexarmarket.ru',
telegram: '@dexarmarket', telegram: '@dexarmarket',
telegramBot: 'dexarmarket_bot', telegramBot: 'DexarSupport_bot',
phones: { phones: {
russia: '+7 (926) 459-31-57', russia: '+7 (926) 459-31-57',
armenia: '+374 94 86 18 16' armenia: '+374 94 86 18 16'
} },
useMockData: false
}; };

View File

@@ -1,16 +1,17 @@
// Dexar Market Configuration // Dexar Market Configuration
export const environment = { export const environment = {
production: false, production: false,
useMockData: false, // Toggle to test with backOffice mock data
brandName: 'Dexarmarket', brandName: 'Dexarmarket',
brandFullName: 'Dexar Market', brandFullName: 'Dexar Market',
theme: 'dexar', theme: 'dexar',
apiUrl: 'https://api.dexarmarket.ru:445', apiUrl: '/api',
logo: '/assets/images/dexar-logo.svg', logo: '/assets/images/dexar-logo.svg',
contactEmail: 'info@dexarmarket.ru', contactEmail: 'info@dexarmarket.ru',
supportEmail: 'info@dexarmarket.ru', supportEmail: 'info@dexarmarket.ru',
domain: 'dexarmarket.ru', domain: 'dexarmarket.ru',
telegram: '@dexarmarket', telegram: '@dexarmarket',
telegramBot: 'dexarmarket_bot', telegramBot: 'DexarSupport_bot',
phones: { phones: {
russia: '+7 (926) 459-31-57', russia: '+7 (926) 459-31-57',
armenia: '+374 94 86 18 16' armenia: '+374 94 86 18 16'

View File

@@ -140,3 +140,58 @@ a, button, input, textarea, select {
.p-3 { padding: 1.5rem; } .p-3 { padding: 1.5rem; }
.p-4 { padding: 2rem; } .p-4 { padding: 2rem; }
// ─── Shared Badge & Tag Styles (from backOffice integration) ───
.item-badges-overlay {
position: absolute;
top: 8px;
left: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
z-index: 2;
}
.item-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #fff;
line-height: 1.4;
&.badge-new { background: #4caf50; }
&.badge-sale { background: #f44336; }
&.badge-exclusive { background: #9c27b0; }
&.badge-hot { background: #ff5722; }
&.badge-limited { background: #ff9800; }
&.badge-bestseller { background: #2196f3; }
&.badge-featured { background: #607d8b; }
&.badge-custom { background: #78909c; }
}
.item-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.72rem;
color: var(--primary-color);
background: rgba(73, 118, 113, 0.08);
border: 1px solid rgba(73, 118, 113, 0.15);
}
.item-simple-desc {
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.4;
margin: 2px 0 4px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}

4
start
View File

@@ -1,2 +1,2 @@
pm2 start "ng serve --configuration=novo --host 127.0.0.1 --port 4000" --name novo-market pm2 start "ng serve --configuration=novo --host 127.0.0.1 --port 4010" --name novo-market
pm2 start "ng serve --host 127.0.0.1 --port 3000" --name dexar-market pm2 start "ng serve --host 127.0.0.1 --port 4001" --name dexar-market