8 Commits

Author SHA1 Message Date
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
sdarbinyan
af78c053ba fixed design 2026-03-05 20:45:15 +04:00
sdarbinyan
7b18376d28 added info for legal 2026-03-05 20:23:42 +04: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
81 changed files with 1431 additions and 4701 deletions

View File

@@ -175,26 +175,6 @@
},
"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,726 +1,168 @@
# Complete Backend API Documentation
# Backend API Changes Required
> **Last updated:** February 2026
> **Frontend:** Angular 21 · Dual-brand (Dexar + Novo)
> **Covers:** Catalog, Cart, Payments, Reviews, Regions, Auth, i18n, BackOffice
## Cart Quantity Support
---
### 1. Add Quantity to Cart Items
## Base URLs
| 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` |
---
## 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`:**
**Current GET /cart Response:**
```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": []
}
]
"itemID": 123,
"name": "Product Name",
"price": 100,
"currency": "RUB",
...other item fields
}
]
```
**Category object:**
**NEW Required Response:**
```json
[
{
"itemID": 123,
"name": "Product Name",
"price": 100,
"currency": "RUB",
"quantity": 2, // <-- ADD THIS FIELD
...other item fields
}
]
```
| 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 |
### 2. POST /cart - Add Item to Cart
**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`:**
**Current Request:**
```json
{
"items": [ /* Item objects */ ],
"total": 128,
"count": 50,
"skip": 0
"itemID": 123
}
```
---
### `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.
**NEW Request (with optional quantity):**
```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
"quantity": 1 // Optional, defaults to 1 if not provided
}
```
**Full Item fields:**
**Behavior:**
- If item already exists in cart, **increment** the quantity by the provided amount
- If item doesn't exist, add it with the specified quantity
| 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) |
### 3. PATCH /cart - Update Item Quantity (NEW ENDPOINT)
**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:**
**Request:**
```json
{
"amount": 89990,
"itemID": 123,
"quantity": 5 // New quantity value (not increment, but absolute value)
}
```
**Response:**
```json
{
"message": "Cart updated successfully"
}
```
**Behavior:**
- Set the quantity to the exact value provided
- If quantity is 0 or negative, remove the item from cart
### 4. Payment Endpoints - Include Quantity
**POST /payment/create**
Update the items array to include quantity:
**Current:**
```json
{
"amount": 1000,
"currency": "RUB",
"siteuserID": "tg_123456789",
"siteorderID": "order_abc123",
"redirectUrl": "",
"telegramUsername": "john_doe",
"items": [
{ "itemID": 123, "price": 89990, "name": "iPhone 15 Pro" }
{
"itemID": 123,
"price": 500,
"name": "Product Name"
}
]
}
```
**Response `200`:**
**NEW:**
```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",
"amount": 1000,
"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"
"items": [
{
"itemID": 123,
"price": 500,
"name": "Product Name",
"quantity": 2 // <-- ADD THIS FIELD
}
]
}
```
| `paymentStatus` values | Meaning |
|------------------------|---------------------------|
| `CREATED` | QR generated, not paid |
| `WAITING` | Payment in progress |
| `COMPLETED` | Payment successful |
| `EXPIRED` | QR code expired |
| `CANCELLED` | Payment cancelled |
### 5. Email Purchase Confirmation
---
**POST /purchase-email**
### `POST /purchase-email` — Submit email after payment
Update items to include quantity:
**Request body:**
**NEW:**
```json
{
"email": "user@example.com",
"telegramUserId": "123456789",
"telegramUserId": "123456",
"items": [
{ "itemID": 123, "name": "iPhone 15 Pro", "price": 89990, "currency": "RUB" }
{
"itemID": 123,
"name": "Product Name",
"price": 500,
"currency": "RUB",
"quantity": 2 // <-- ADD THIS FIELD
}
]
}
```
**Response `200`:**
```json
{ "message": "Email sent" }
## Future: Filters & Sorting (To Be Discussed)
### GET /category/{categoryID}
Add query parameters for filtering and sorting:
**Proposed Query Parameters:**
- `sort`: Sort order (e.g., `price_asc`, `price_desc`, `rating_desc`, `name_asc`)
- `minPrice`: Minimum price filter
- `maxPrice`: Maximum price filter
- `minRating`: Minimum rating filter (1-5)
- `count`: Number of items per page (already exists)
- `skip`: Offset for pagination (already exists)
**Example:**
```
GET /category/5?sort=price_asc&minPrice=100&maxPrice=500&minRating=4&count=20&skip=0
```
**Response:** Same as current (array of items)
---
## 6. Reviews / Comments
## Summary
### `POST /comment` — Submit a review
**Required NOW:**
1. Add `quantity` field to cart item responses
2. Support `quantity` parameter in POST /cart
3. Create new PATCH /cart endpoint for updating quantities
4. Include `quantity` in payment and email endpoints
**Request body:**
```json
{
"itemID": 123,
"rating": 5,
"comment": "Great product!",
"username": "john_doe",
"userId": 123456789,
"timestamp": "2026-02-28T12:00:00Z"
}
```
**Response `200`:**
```json
{ "message": "Review submitted" }
```
---
## 7. Regions
### `GET /regions` — List available regions
Returns regions where the marketplace operates.
**Response `200`:**
```json
[
{
"id": "moscow",
"city": "Москва",
"country": "Россия",
"countryCode": "RU",
"timezone": "Europe/Moscow"
},
{
"id": "spb",
"city": "Санкт-Петербург",
"country": "Россия",
"countryCode": "RU",
"timezone": "Europe/Moscow"
},
{
"id": "yerevan",
"city": "Ереван",
"country": "Армения",
"countryCode": "AM",
"timezone": "Asia/Yerevan"
}
]
```
**Region object:**
| Field | Type | Required | Description |
|---------------|--------|----------|--------------------------|
| `id` | string | yes | Unique region identifier |
| `city` | string | yes | City name (display) |
| `country` | string | yes | Country name |
| `countryCode` | string | yes | ISO 3166-1 alpha-2 |
| `timezone` | string | no | IANA timezone |
> **Fallback:** If this endpoint is down, the frontend uses 6 hardcoded defaults: Moscow, SPB, Yerevan, Minsk, Almaty, Tbilisi.
---
## 8. Authentication (Telegram Login)
Authentication is **Telegram-based** with **cookie sessions** (HttpOnly, Secure, SameSite=None).
All auth endpoints must include `withCredentials: true` CORS support.
### Auth flow
```
1. User clicks "Checkout" → not authenticated → login dialog shown
2. User clicks "Log in with Telegram" → opens https://t.me/{bot}?start=auth_{callback}
3. User starts the bot in Telegram
4. Bot sends user data → backend /auth/telegram/callback
5. Backend creates session → sets Set-Cookie
6. Frontend polls GET /auth/session every 3s
7. Session detected → dialog closes → checkout proceeds
```
---
### `GET /auth/session` — Check current session
**Request:** Cookies only (session cookie set by backend).
**Response `200`** (authenticated):
```json
{
"sessionId": "sess_abc123",
"telegramUserId": 123456789,
"username": "john_doe",
"displayName": "John Doe",
"active": true,
"expiresAt": "2026-03-01T12:00:00Z"
}
```
**Response `200`** (expired):
```json
{
"sessionId": "sess_abc123",
"telegramUserId": 123456789,
"username": "john_doe",
"displayName": "John Doe",
"active": false,
"expiresAt": "2026-02-27T12:00:00Z"
}
```
**Response `401`** (no session):
```json
{ "error": "No active session" }
```
**AuthSession object:**
| Field | Type | Required | Description |
|------------------|---------|----------|--------------------------------------------|
| `sessionId` | string | yes | Unique session ID |
| `telegramUserId` | number | yes | Telegram user ID |
| `username` | string? | no | Telegram @username (can be null) |
| `displayName` | string | yes | User display name (first + last) |
| `active` | boolean | yes | Whether session is valid |
| `expiresAt` | string | yes | ISO 8601 expiration datetime |
---
### `GET /auth/telegram/callback` — Telegram bot auth callback
Called by the Telegram bot after user authenticates.
**Request body (from bot):**
```json
{
"id": 123456789,
"first_name": "John",
"last_name": "Doe",
"username": "john_doe",
"photo_url": "https://t.me/i/userpic/...",
"auth_date": 1709100000,
"hash": "abc123def456..."
}
```
**Response:** Must set a session cookie and return:
```json
{
"sessionId": "sess_abc123",
"message": "Authenticated successfully"
}
```
**Cookie requirements:**
| Attribute | Value | Notes |
|------------|----------------|--------------------------------------------|
| `HttpOnly` | `true` | Not accessible via JS |
| `Secure` | `true` | HTTPS only |
| `SameSite` | `None` | Required for cross-origin (API ≠ frontend) |
| `Path` | `/` | |
| `Max-Age` | `86400` (24h) | Or as needed |
---
### `POST /auth/logout` — End session
**Request:** Cookies only, empty body `{}`
**Response `200`:**
```json
{ "message": "Logged out" }
```
Must clear/invalidate the session cookie.
---
### Session refresh
The frontend re-checks the session **60 seconds before `expiresAt`**. If the backend supports sliding expiration, it can reset the cookie's `Max-Age` on each `GET /auth/session`.
---
## 9. i18n / Translations
The frontend supports 3 languages: **Russian (ru)**, **English (en)**, **Armenian (hy)**.
The active language is sent via the `X-Language` HTTP header on every request.
### What the backend should do with `X-Language`
1. **Categories & items**: If `translations` field exists for the requested language, return the translated `name`, `description`, etc. OR the backend can apply translations server-side and return already-translated fields.
2. **The `translations` field** on items (optional approach):
```json
{
"translations": {
"en": {
"name": "iPhone 15 Pro",
"simpleDescription": "Short desc in English",
"description": [{ "key": "Processor", "value": "A17 Pro" }]
},
"hy": {
"name": "iPhone 15 Pro",
"simpleDescription": "Կarcheck check"
}
}
}
```
3. **Recommended approach**: Read `X-Language` header and return the `name`/`description` in that language directly. If no translation exists, return the Russian default.
---
## 10. CORS Configuration
For auth cookies and custom headers to work, the backend CORS config must include:
```
Access-Control-Allow-Origin: https://dexarmarket.ru (NOT wildcard *)
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, X-Region, X-Language
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
```
> **Important:** `Access-Control-Allow-Origin` cannot be `*` when `Allow-Credentials: true`. Must be the exact frontend origin.
**Allowed origins:**
- `https://dexarmarket.ru`
- `https://novo.market`
- `http://localhost:4200` (dev)
- `http://localhost:4201` (dev, Novo)
---
## 11. Telegram Bot Setup
Each brand needs its own bot:
- **Dexar:** `@dexarmarket_bot`
- **Novo:** `@novomarket_bot`
The bot should:
1. Listen for `/start auth_{callbackUrl}` command
2. Extract the callback URL
3. Send the user's Telegram data (`id`, `first_name`, `username`, etc.) to that callback URL
4. The callback URL is `{apiUrl}/auth/telegram/callback`
---
## Complete Endpoint Reference
### New endpoints
| Method | Path | Description | Auth |
|--------|---------------------------|----------------------------|----------|
| `GET` | `/regions` | List available regions | No |
| `GET` | `/auth/session` | Check current session | Cookie |
| `GET` | `/auth/telegram/callback` | Telegram bot auth callback | No (bot) |
| `POST` | `/auth/logout` | End session | Cookie |
### Existing endpoints
| Method | Path | Description | Auth | Headers |
|----------|-----------------------|-------------------------|------|--------------------|
| `GET` | `/ping` | Health check | No | — |
| `GET` | `/category` | List categories | No | X-Region, X-Language |
| `GET` | `/category/:id` | Items in category | No | X-Region, X-Language |
| `GET` | `/item/:id` | Single item | No | X-Region, X-Language |
| `GET` | `/searchitems` | Search items | No | X-Region, X-Language |
| `GET` | `/randomitems` | Random items | No | X-Region, X-Language |
| `POST` | `/cart` | Add to cart / Payment | No* | — |
| `PATCH` | `/cart` | Update cart quantity | No* | — |
| `DELETE` | `/cart` | Remove from cart | No* | — |
| `GET` | `/cart` | Get cart contents | No* | — |
| `POST` | `/comment` | Submit review | No | — |
| `GET` | `/qr/payment/:qrId` | Check payment status | No | — |
| `POST` | `/purchase-email` | Submit email after pay | No | — |
> \* Cart/payment endpoints may use the session cookie if available for order association, but don't strictly require auth. The frontend enforces auth before checkout.
**Future (After Discussion):**
- Sorting and filtering query parameters for category items endpoint

View File

@@ -1,726 +0,0 @@
# Полная документация 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,500 @@
bro we need to do changes, that client required
1. we need to add location logic
1.1 the catalogs will come or for global or for exact region
1.2 need to add a place where the user can choose his region like city if choosed moscow the country is set russian
1.3 can we try to understand what country is user logged or whach city by global ip and set it?
2. we need to add somekind of user login logic
2.1 user can add to cart, look the items and etc without logged in, but when he is going to buy/pay ->
at first he have to login with telegram, i will send you the bots adress.
2.1.1 if is not logged -> will see the QR or link for logging via telegram
2.1.2 if logged we need to ping server to check if he is active user. the expiration date (like day or 5 days) we will get from bakcend with session id
2.2 and when user is logged, that time he can do a payment
we ae going to redesing dexar. here are css from the figma. i will try to explain all.
pls do responsive and better! thank you
you are free to do changes better and responsive ofc!!
Header:
<div class="frame">
<img class="group" src="img/group-2.png" />
<div class="div">
<div class="div-wrapper"><div class="text-wrapper">Главная</div></div>
<div class="div-wrapper-2"><div class="text-wrapper">О нас</div></div>
<div class="div-wrapper-3"><div class="text-wrapper-2">Контакты</div></div>
</div>
<div class="frame-wrapper">
<div class="div-2">
<div class="text-wrapper-3">Искать...</div>
<img class="icn" src="img/icn-05.png" />
</div>
</div>
<div class="korzina-frame"><img class="cart" src="img/cart.svg" /></div>
<div class="RU-frame">
<div class="text-wrapper-4">RU</div>
<div class="group-2"><img class="line" src="img/line-2.svg" /> <img class="img" src="img/line-3.svg" /></div>
</div>
<div class="login-frame"><img class="icon" src="img/icon.svg" /></div>
</div>
.frame {
width: 1440px;
height: 84px;
display: flex;
background-color: #74787b1a;
}
.frame .group {
margin-top: 18px;
width: 148px;
height: 48px;
position: relative;
margin-left: 56px;
}
.frame .div {
display: inline-flex;
margin-top: 18px;
width: 569px;
height: 49px;
position: relative;
margin-left: 57px;
align-items: flex-start;
}
.frame .div-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 48px;
position: relative;
flex: 0 0 auto;
background-color: #497671;
border-radius: 13px 0px 0px 13px;
border: 1px solid;
border-color: #d3dad9;
box-shadow: 0px 3px 4px #00000026;
}
.frame .text-wrapper {
position: relative;
width: fit-content;
margin-top: -1.00px;
font-family: "DM Sans-SemiBold", Helvetica;
font-weight: 600;
color: #ffffff;
font-size: 22px;
text-align: center;
letter-spacing: 0;
line-height: normal;
}
.frame .div-wrapper-2 {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 63px;
position: relative;
flex: 0 0 auto;
background-color: #a1b4b5;
border: 1px solid;
border-color: #d3dad9;
box-shadow: 0px 3px 4px #00000026;
}
.frame .div-wrapper-3 {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 42px;
position: relative;
flex: 0 0 auto;
background-color: #ffffffbd;
border-radius: 0px 13px 13px 0px;
border: 1px solid;
border-color: #d3dad9;
box-shadow: 0px 3px 4px #00000026;
}
.frame .text-wrapper-2 {
color: #1e3c38;
position: relative;
width: fit-content;
margin-top: -1.00px;
font-family: "DM Sans-SemiBold", Helvetica;
font-weight: 600;
font-size: 22px;
text-align: center;
letter-spacing: 0;
line-height: normal;
}
.frame .frame-wrapper {
margin-top: 18px;
width: 234px;
height: 49px;
position: relative;
margin-left: 126px;
background-color: #ffffffbd;
border-radius: 22px;
border: 1px solid;
border-color: #d2dad9;
box-shadow: 0px 3px 4px #00000026;
}
.frame .div-2 {
display: inline-flex;
align-items: center;
gap: 27px;
padding: 0px 20px;
position: relative;
top: 10px;
left: 50px;
}
.frame .text-wrapper-3 {
color: #828e8d;
position: relative;
width: fit-content;
margin-top: -1.00px;
font-family: "DM Sans-SemiBold", Helvetica;
font-weight: 600;
font-size: 22px;
text-align: center;
letter-spacing: 0;
line-height: normal;
}
.frame .icn {
position: absolute;
top: 1px;
left: -32px;
width: 28px;
height: 28px;
}
.frame .korzina-frame {
margin-top: 26px;
width: 48px;
height: 32px;
position: relative;
margin-left: 57px;
background-color: #ffffff4c;
border-radius: 12px;
border: 1px solid;
border-color: #667a77;
}
.frame .cart {
position: absolute;
top: calc(50.00% - 13px);
left: calc(50.00% - 14px);
width: 27px;
height: 27px;
}
.frame .RU-frame {
display: flex;
margin-top: 26px;
width: 67px;
height: 32px;
position: relative;
margin-left: 4px;
align-items: center;
gap: 8px;
padding: 6px;
background-color: #ffffff4c;
border-radius: 12px;
border: 1px solid;
border-color: #667a77;
}
.frame .text-wrapper-4 {
position: relative;
width: fit-content;
margin-top: -6.50px;
margin-bottom: -4.50px;
font-family: "DM Sans-Medium", Helvetica;
font-weight: 500;
color: #1e3c38;
font-size: 24px;
letter-spacing: 0;
line-height: normal;
}
.frame .group-2 {
position: relative;
width: 9.29px;
height: 14px;
transform: rotate(90.00deg);
}
.frame .line {
top: -2px;
position: absolute;
left: 1px;
width: 9px;
height: 10px;
transform: rotate(-90.00deg);
}
.frame .img {
top: 6px;
position: absolute;
left: 1px;
width: 9px;
height: 10px;
transform: rotate(-90.00deg);
}
.frame .login-frame {
margin-top: 26px;
width: 48px;
height: 32px;
position: relative;
margin-left: 4px;
background-color: #ffffff4c;
border-radius: 12px;
border: 1px solid;
border-color: #667a77;
}
.frame .icon {
position: absolute;
top: calc(50.00% - 12px);
left: calc(50.00% - 12px);
width: 24px;
height: 24px;
}
1. background: rgba(117, 121, 124, 0.1);
padding: 14px 0px;
width: 1440px;
height: 84px;
2. logo stays the
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 308.43 100.53">
<defs>
<style>
.cls-1 {
fill: #477470;
stroke-width: 0px;
}
</style>
</defs>
<path class="cls-1" d="m101.66,15.71c-4.16-.3-8.34-.35-12.51-.46-3.85-.1-7.69-.15-11.54-.21-9.14-.15-18.29-.32-27.44-.44-7.84-.11-15.68-.18-23.53-.21-.83,0-1.17-.3-1.33-1.01-.81-3.51-1.64-7.02-2.44-10.53-.31-1.33-1.42-2.36-2.68-2.41-1.59-.07-3.18-.17-4.77-.21C11.37.13,7.31.06,3.25,0,1.27-.03,0,1.13,0,2.92c0,1.78,1.38,3.14,3.26,3.17,4.28.08,8.56.17,12.84.2.89,0,1.34.26,1.56,1.17,1.2,4.99,2.47,9.95,3.69,14.93,2.3,9.38,4.58,18.77,6.88,28.15,1.11,4.54,2.21,9.07,3.36,13.6.28,1.11.15,1.73-1.02,2.31-3.76,1.85-5.33,5.91-4.45,9.93.91,4.11,4.58,6.95,9.07,7.02.46,0,.92,0,1.38,0-2.97,1.75-4.68,4.13-4.95,7.42-.27,3.32,1.42,5.8,3.95,7.96-4.85.74-6.27.75-9.41,1.23.8.23,1.31.11,1.98.12,4.46.05,8.92.17,13.37.01,4.94-.17,8.86-5.16,7.57-10.63-.63-2.66-2.21-4.7-5.04-5.9h39.73c-2.87,1.74-4.53,4.14-4.85,7.36-.32,3.29,1.08,5.9,3.89,8.11-9.01.38-17.71.47-26.34,1.09l30.02.35c1.84-.07,3.73.03,5.49-.97,4.82-2.75,6.23-8.3,3.26-12.73-.84-1.26-2.17-2.19-3.21-3.2,1.3,0,2.83.03,4.35,0,1.66-.04,2.81-1.34,2.78-3.08-.02-1.56-1.25-2.77-2.82-2.79-6.68-.07-13.36-.18-20.04-.2-9.37-.04-18.74-.01-28.11-.02-4.25,0-8.5,0-12.75,0-2.17,0-3.72-1.47-3.62-3.37.09-1.79,1.73-3.16,3.83-3.15,8.39.04,16.77.1,25.16.13,8.61.04,17.21.06,25.82.07.97,0,1.94-.09,2.9-.21,3.83-.52,6.67-3.16,7.69-6.89,1.84-6.75,3.76-13.47,5.65-20.21,1.36-4.84,2.79-9.66,4.08-14.52.59-2.2,1.13-4.45,1.32-6.7.29-3.53-2.89-6.7-6.6-6.96Zm-13.8,71.86c2.2-.07,4.11,1.95,4.1,4.15-.18,2.67-1.84,3.97-4.24,4.07-2.17.08-4.06-1.98-4.03-4.18.03-2.3,1.72-3.96,4.17-4.04Zm-47.43-.03c2.45-.06,4.19,1.8,4.15,4.03-.05,2.63-2.02,3.98-4.06,4.02-2.23.04-4.05-1.86-4.15-4.07-.1-2.22,2.05-4.07,4.06-3.98Zm30.45-67.01v12.33c-1.89,0-3.69.02-5.48,0-3.15-.05-6.3-.18-9.45-.18-.98,0-1.2-.35-1.27-1.24-.22-2.76-.55-5.5-.82-8.25-.09-.93-.15-1.86-.21-2.66h17.23Zm-.14,17.64v12.64c-4.47,0-8.88.02-13.29-.04-.26,0-.71-.63-.75-1.01-.35-3.18-.62-6.37-.91-9.55,0-.04,0-.07,0-.11-.15-1.98-.15-1.95,1.83-1.94,4.35.02,8.69,0,13.13,0Zm-41.31-8.1c-.62-2.71-1.26-5.41-1.88-8.12-.15-.65-.27-1.32-.43-2.1,7.05.12,13.97.24,21.04.37.41,4.15.81,8.23,1.19,12.14-5.73,0-11.3,0-16.87,0-.11,0-.22-.02-.32-.03-2.25-.14-2.24-.14-2.73-2.26Zm5.02,20.67c-1.01-4.24-2.02-8.49-3.03-12.7h18.64c.47,4.3.93,8.46,1.39,12.7h-17.01Zm57.74,8.57c-.3,1.1-.54,2.23-.89,3.31-.51,1.58-1.87,2.54-3.47,2.54-16.08-.01-32.17-.04-48.25,0-1.26,0-1.71-.36-1.95-1.57-.44-2.27-1.1-4.5-1.65-6.75-.04-.17,0-.35,0-.67,18.95.13,37.85.26,56.99.39-.29,1.03-.53,1.89-.77,2.76Zm4.75-16.54c-.7,2.51-1.41,5.02-2.17,7.51-.09.29-.56.65-.85.65-5.59.04-11.18.04-16.77,0-.29,0-.83-.42-.84-.64-.05-3.87-.04-7.75-.04-11.6h21.71c-.38,1.5-.69,2.8-1.05,4.08Zm5.38-19.31c-.83,2.95-1.7,5.89-2.49,8.85-.19.73-.47,1.01-1.23.99-6.45-.16-12.91-.28-19.36-.41-.94-.02-1.88,0-2.97,0,0-3.91.01-7.67,0-11.43,0-.76.45-.78,1-.77,2.83.08,5.65.17,8.48.22,4.93.09,9.86.15,14.79.22,1.49.02,2.18.94,1.78,2.34Z"/>
<path class="cls-1" d="m299.48,39.67c.17-.09.36-.18.54-.28,3.09-1.58,5.27-3.86,5.99-7.4.42-2.08.51-4.14.17-6.22-.51-3.09-1.95-5.6-4.74-7.19-2.92-1.67-6.16-2.13-9.43-2.22-4.54-.13-9.08-.02-13.62-.04-.68,0-.98.18-.98.92.02,11.58.02,23.15,0,34.73,0,.72.26.96.96.95,1.71-.03,3.41-.03,5.12.02.85.03,1.15-.26,1.14-1.12-.04-3.23-.02-6.46-.02-9.69v-1.18c2.28,0,4.38.04,6.48-.02.77-.02,1.18.27,1.57.87,1.95,3.04,4,6.02,5.85,9.11.89,1.49,1.85,2.24,3.68,2.06,1.95-.2,3.94-.04,6.23-.04-3.09-4.57-6.01-8.89-8.95-13.25Zm-.65-8.49c-.41,1.92-1.85,2.99-3.63,3.16-3.3.31-6.64.33-9.96.42-.2,0-.59-.48-.59-.74-.04-3.81-.03-7.61-.03-11.8,3.68.22,7.25.24,10.77.71,2.49.33,3.8,2.22,3.81,4.75,0,1.17-.13,2.36-.37,3.51Z"/>
<path class="cls-1" d="m160.88,43.32c2.31-4.64,2.45-9.55,1.34-14.5-.78-3.47-2.57-6.41-5.35-8.65-3.79-3.05-8.3-4.12-13.04-4.26-3.99-.11-7.99.01-11.98-.05-1.08-.02-1.33.33-1.33,1.36.03,11.35.02,22.71.02,34.06v1.2c3.27,0,6.38.06,9.5-.02,2.92-.07,5.87-.03,8.73-.48,5.42-.85,9.62-3.66,12.11-8.67Zm-5.96-4c-1.11,3.56-4.21,6.16-7.89,6.59-2.68.32-5.41.24-8.12.41-.96.06-1.17-.33-1.16-1.19.03-3.66.01-7.32.01-10.99.02,0,.03,0,.05,0,0-3.7-.01-7.4.02-11.09,0-.28.34-.81.52-.81,3.16.01,6.35-.32,9.47.56,4.39,1.24,6.86,4.16,7.57,8.62.43,2.66.34,5.3-.47,7.88Z"/>
<path class="cls-1" d="m176.08,37.91c0-.65.38-.66.86-.65,3.92.06,7.84.12,11.76.16,1.36.02,2.72,0,4.17,0,0-1.95-.04-3.62.02-5.28.03-.84-.28-1.03-1.07-1.02-4.83.03-9.66.02-14.49.02h-1.27c0-2.91-.01-5.7.03-8.48,0-.17.43-.48.66-.48,5.15-.02,10.31-.01,15.46-.01.47,0,.94-.05,1.42-.03.73.04,1.03-.22,1-1-.06-1.27-.07-2.54,0-3.81.06-.94-.22-1.25-1.2-1.24-7.04.03-14.09,0-21.13,0-1.11,0-2.22,0-3.31,0v36.58h25.96v-6.21h-18.86c0-2.98,0-5.76,0-8.55Z"/>
<path class="cls-1" d="m265.06,35c-2.49-6.04-4.99-12.08-7.52-18.1-.12-.28-.65-.53-1-.54-1.92-.05-3.85,0-5.77-.04-.7-.02-1,.27-1.26.89-2.73,6.57-5.49,13.12-8.23,19.68-2.17,5.21-4.32,10.42-6.61,15.95,2.43,0,4.65.03,6.86-.04.34-.01.81-.44.96-.79.93-2.17,1.76-4.38,2.69-6.55.15-.34.61-.79.93-.79,4.94.01,9.87.11,14.81.13.67,0,.84.31,1.04.81.86,2.16,1.73,4.31,2.63,6.45.11.26.38.65.59.65,2.34.05,4.68.03,7.12.03-.11-.33-.19-.63-.31-.91-2.3-5.62-4.6-11.23-6.91-16.84Zm-17.29,3.48c1.91-4.7,3.81-9.35,5.79-14.21,1.96,4.85,3.84,9.48,5.76,14.21h-11.54Z"/>
<path class="cls-1" d="m225.35,52.65c2.59.09,5.19.05,7.88.05-.08-.32-.09-.51-.18-.64-1.34-1.94-2.7-3.86-4.04-5.8-2.54-3.68-5.05-7.38-7.59-11.06-.54-.78-.8-1.41-.12-2.37,2.6-3.69,5.06-7.47,7.59-11.21,1.18-1.74,2.4-3.46,3.72-5.35-.47-.07-.71-.13-.95-.13-2.11,0-4.21-.06-6.32.03-.52.02-1.21.36-1.51.77-1.3,1.77-2.49,3.62-3.72,5.43-1.3,1.92-2.61,3.85-3.96,5.84-.26-.31-.43-.49-.57-.7-2.13-3.22-4.31-6.4-6.36-9.67-.79-1.26-1.63-1.88-3.2-1.76-2.04.17-4.09.04-6.28.04.14.36.18.57.29.73,3.71,5.4,7.42,10.8,11.15,16.19.43.62.42,1.09-.02,1.72-3.29,4.7-6.54,9.42-9.8,14.14-.83,1.21-1.63,2.45-2.53,3.81,2.74,0,5.24.02,7.74-.02.31,0,.73-.26.92-.53,2.4-3.49,4.77-7,7.15-10.51.45-.67.9-1.34,1.38-2.05,2.79,4.08,5.5,8.05,8.23,12,.29.42.72,1.05,1.1,1.06Z"/>
<path class="cls-1" d="m141.52,77.32l-1.21,2.83h-.11l-1.21-2.83-3.33-7.36h-3.58v14.94h2.99v-6.83c0-1.39-.25-3.38-.4-4.75h.11l1.47,3.4,3.19,6.78h1.5l3.19-6.78,1.5-3.4h.11c-.17,1.37-.42,3.36-.42,4.75v6.83h3.08v-14.94h-3.61l-3.24,7.36Z"/>
<path class="cls-1" d="m162.26,69.96l-6.04,14.94h3.36l1.44-4.04h6.18l1.44,4.04h3.47l-6.01-14.94h-3.84Zm-.51,8.82l.65-1.83c.59-1.58,1.13-3.27,1.64-4.93h.11c.54,1.64,1.1,3.36,1.66,4.93l.65,1.83h-4.71Z"/>
<path class="cls-1" d="m192.96,74.39c0-3.34-2.96-4.43-6.8-4.43h-6.21v14.94h3.27v-5.85h2.79l3.98,5.85h3.67l-4.4-6.24c2.23-.62,3.7-1.99,3.7-4.27Zm-7.14,2.56h-2.6v-4.87h2.6c2.54,0,3.89.59,3.89,2.31s-1.35,2.56-3.89,2.56Z"/>
<polygon class="cls-1" points="215.96 69.96 212.34 69.96 205.77 76.75 205.69 76.75 205.69 69.96 202.41 69.96 202.41 84.9 205.69 84.9 205.69 80.54 208.34 77.87 213.3 84.9 216.92 84.9 210.29 75.79 215.96 69.96"/>
<polygon class="cls-1" points="228.09 78.25 234.72 78.25 234.72 76.01 228.09 76.01 228.09 72.2 235.9 72.2 235.9 69.96 224.82 69.96 224.82 84.9 236.19 84.9 236.19 82.66 228.09 82.66 228.09 78.25"/>
<polygon class="cls-1" points="243.92 72.2 249.25 72.2 249.25 84.9 252.52 84.9 252.52 72.2 257.83 72.2 257.83 69.96 243.92 69.96 243.92 72.2"/>
</svg>
3. after logo 3 btns in same div and without gap
3.1 "главная"
border: 1px solid #d3dad9;
border-radius: 13px 0 0 13px;
padding: 10px 48px;
width: 187px;
height: 49px;
3.2 "о нас"border:
1px solid #d3dad9;
padding: 10px 63px;
width: 188px;
height: 49px;
3.3 "котакты"border:
1px solid #d3dad9;
border-radius: 0 13px 13px 0;
padding: 10px 42px;
width: 194px;
height: 49px;
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
background: rgba(255, 255, 255, 0.74);
hover: background: #a1b4b5;
active : background: #497671;
4. next search btn with place holder "искать..." and on the left fixed svg icon "<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12Z" fill="#576463" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.2929 18.2929C18.6834 17.9024 19.3166 17.9024 19.7071 18.2929L25.7071 24.2929C26.0976 24.6834 26.0976 25.3166 25.7071 25.7071C25.3166 26.0976 24.6834 26.0976 24.2929 25.7071L18.2929 19.7071C17.9024 19.3166 17.9024 18.6834 18.2929 18.2929Z" fill="#576463" />
</svg>"
border: 1px solid #d3dad9;
border-radius: 22px;
padding: 6px 10px;
width: 234px;
height: 49px;
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
background: rgba(255, 255, 255, 0.74);
5. after 3 buttons to the right
5.1 cart btn
border-radius: 12px;
fill: rgba(255, 255, 255, 0.3);
border: 1px solid #677b78;
<svg width="48" height="32" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" fill="white" fill-opacity="0.3" />
<path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" stroke="#677B78" />
<path d="M10 3.9C10 3.40294 10.4029 3 10.9 3H13.6C14.013 3 14.373 3.28107 14.4731 3.68172L15.2027 6.6H36.1C36.3677 6.6 36.6216 6.7192 36.7925 6.92523C36.9635 7.13125 37.0339 7.40271 36.9846 7.66586L34.2846 22.0659C34.2048 22.4915 33.8331 22.8 33.4 22.8H31.6H19H17.2C16.7669 22.8 16.3952 22.4915 16.3154 22.0659L13.6204 7.69224L12.8973 4.8H10.9C10.4029 4.8 10 4.39706 10 3.9ZM15.5844 8.4L17.9469 21H32.6531L35.0156 8.4H15.5844ZM19 22.8C17.0118 22.8 15.4 24.4118 15.4 26.4C15.4 28.3882 17.0118 30 19 30C20.9882 30 22.6 28.3882 22.6 26.4C22.6 24.4118 20.9882 22.8 19 22.8ZM31.6 22.8C29.6118 22.8 28 24.4118 28 26.4C28 28.3882 29.6118 30 31.6 30C33.5882 30 35.2 28.3882 35.2 26.4C35.2 24.4118 33.5882 22.8 31.6 22.8ZM19 24.6C19.9941 24.6 20.8 25.4059 20.8 26.4C20.8 27.3941 19.9941 28.2 19 28.2C18.0059 28.2 17.2 27.3941 17.2 26.4C17.2 25.4059 18.0059 24.6 19 24.6ZM31.6 24.6C32.5941 24.6 33.4 25.4059 33.4 26.4C33.4 27.3941 32.5941 28.2 31.6 28.2C30.6059 28.2 29.8 27.3941 29.8 26.4C29.8 25.4059 30.6059 24.6 31.6 24.6Z" fill="#1E3C38" />
</svg>
5.2 lang selector btn style border: 1px solid #677b78;
border-radius: 12px;
padding: 6px;
width: 67px;
height: 32px;
HERO
we are goung to have a width wide hero, photos for dekstop and mobile you can see in the same folder
on it text. here are codes from figma
<div class="frame">
<div class="text-wrapper">Здесь ты найдёшь всё</div>
<p class="div">Тысячи товаров в одном месте</p>
<div class="text-wrapper-2">просто и удобно</div>
</div>
.frame {
display: flex;
flex-direction: column;
width: 639px;
align-items: flex-start;
gap: 18px;
position: relative;
}
.frame .text-wrapper {
position: relative;
width: 659px;
margin-top: -1.00px;
margin-right: -20.00px;
font-size: 57px;
font-family: "DM Sans-Medium", Helvetica;
font-weight: 500;
color: #1e3c38;
letter-spacing: 0;
line-height: normal;
}
.frame .div {
position: absolute;
top: 87px;
left: 0;
width: 581px;
font-size: 34px;
font-family: "DM Sans-Medium", Helvetica;
font-weight: 500;
color: #1e3c38;
letter-spacing: 0;
line-height: normal;
}
.frame .text-wrapper-2 {
position: absolute;
top: 133px;
left: 0;
width: 281px;
font-size: 34px;
font-family: "DM Sans-Medium", Helvetica;
font-weight: 500;
color: #1e3c38;
letter-spacing: 0;
line-height: normal;
}
under the text we have btns.. hovers and actives for all web site are the same as from header
first
<div class="pereyti-v-katalog"><div class="text-wrapper">Перейти в каталог</div></div>
.pereyti-v-katalog {
width: 337px;
height: 60px;
display: flex;
border-radius: 13px;
border: 1px solid;
border-color: #d3dad9;
background: linear-gradient(
360deg,
rgba(73, 118, 113, 1) 0%,
rgba(167, 206, 202, 1) 100%
);
}
.pereyti-v-katalog .text-wrapper {
margin-top: 12px;
width: 269px;
height: 36px;
margin-left: 34px;
position: relative;
font-family: "DM Sans-Medium", Helvetica;
font-weight: 500;
color: #ffffff;
font-size: 27px;
text-align: center;
letter-spacing: 1.08px;
line-height: normal;
}
second btn
<div class="frame">
<div class="text-wrapper">Найти товар</div>
<div class="group"><img class="line" src="img/line-2.svg" /> <img class="img" src="img/line-3.svg" /></div>
</div>
.frame {
width: 264px;
height: 60px;
display: flex;
gap: 9.2px;
background-color: #f5f5f5;
border-radius: 13px;
border: 1px solid;
border-color: #d3dad9;
}
.frame .text-wrapper {
margin-top: 12px;
width: 181px;
height: 36px;
position: relative;
margin-left: 36px;
font-family: "DM Sans-Medium", Helvetica;
font-weight: 500;
color: #1e3c38;
font-size: 27px;
text-align: center;
letter-spacing: 1.08px;
line-height: normal;
}
.frame .group {
margin-top: 22.0px;
width: 10.62px;
height: 16px;
position: relative;
}
.frame .line {
top: -1px;
width: 12px;
position: absolute;
left: 1px;
height: 10px;
}
.frame .img {
top: 7px;
width: 11px;
position: absolute;
left: 1px;
height: 10px;
}

View File

@@ -36,6 +36,9 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
# Brotli compression (if available)
# brotli on;

72
package-lock.json generated
View File

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

View File

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

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 - Интернет-магазин",
"short_name": "Novo",
"description": "Novo Market - ваш онлайн магазин качественных товаров с доставкой",
@@ -12,34 +11,10 @@
"categories": ["shopping", "lifestyle"],
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
"src": "assets/images/novo-favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "icons/icon-192x192.png",
@@ -47,12 +22,6 @@
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",

View File

@@ -11,34 +11,10 @@
"categories": ["shopping", "marketplace"],
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
"src": "assets/images/dexar-favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "icons/icon-192x192.png",
@@ -46,12 +22,6 @@
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",

View File

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

View File

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

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

@@ -5,7 +5,6 @@ import { Title } from '@angular/platform-browser';
import { HeaderComponent } from './components/header/header.component';
import { FooterComponent } from './components/footer/footer.component';
import { BackButtonComponent } from './components/back-button/back-button.component';
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
import { ApiService } from './services';
import { interval, concat } from 'rxjs';
import { filter, first } from 'rxjs/operators';
@@ -17,7 +16,7 @@ import { TranslateService } from './i18n/translate.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TelegramLoginComponent, TranslatePipe],
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe],
templateUrl: './app.html',
styleUrl: './app.scss'
})

View File

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

View File

@@ -27,11 +27,10 @@
</nav>
<div class="novo-right">
<app-region-selector />
<app-language-selector />
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()" [attr.aria-label]="'header.cart' | translate">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
@@ -41,7 +40,7 @@
}
</a>
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen">
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
<span></span>
<span></span>
<span></span>
@@ -107,18 +106,13 @@
}
</a>
<!-- Region Selector (desktop only) -->
<div class="dexar-region-selector dexar-lang-desktop">
<app-region-selector />
</div>
<!-- Language Selector (desktop only) -->
<div class="dexar-lang-selector dexar-lang-desktop">
<app-language-selector />
</div>
<!-- Mobile Menu Toggle -->
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen">
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
<span></span>
<span></span>
<span></span>
@@ -177,11 +171,6 @@
</svg>
</a>
<!-- Region Selector in mobile menu -->
<div class="dexar-mobile-lang">
<app-region-selector />
</div>
<!-- Language Selector in mobile menu -->
<div class="dexar-mobile-lang">
<app-language-selector />

View File

@@ -4,13 +4,12 @@ import { CartService, LanguageService } from '../../services';
import { environment } from '../../../environments/environment';
import { LogoComponent } from '../logo/logo.component';
import { LanguageSelectorComponent } from '../language-selector/language-selector.component';
import { RegionSelectorComponent } from '../region-selector/region-selector.component';
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
@Component({
selector: 'app-header',
imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, RegionSelectorComponent, LangRoutePipe, TranslatePipe],
imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, LangRoutePipe, TranslatePipe],
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -22,13 +22,6 @@
@if (product.discount > 0) {
<span class="discount-badge">-{{ product.discount }}%</span>
}
@if (product.badges && product.badges.length > 0) {
<div class="item-badges-overlay">
@for (badge of product.badges; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
</div>
<div class="item-details">

View File

@@ -7,7 +7,7 @@ import { TagModule } from 'primeng/tag';
import { ApiService, CartService } from '../../services';
import { Item } from '../../models';
import { environment } from '../../../environments/environment';
import { getDiscountedPrice, getMainImage, getBadgeClass } from '../../utils/item.utils';
import { getDiscountedPrice, getMainImage } from '../../utils/item.utils';
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
@@ -98,7 +98,6 @@ export class ItemsCarouselComponent implements OnInit {
readonly getItemImage = getMainImage;
readonly getDiscountedPrice = getDiscountedPrice;
readonly getBadgeClass = getBadgeClass;
addToCart(event: Event, item: Item): void {
event.preventDefault();

View File

@@ -1,5 +1,5 @@
<div class="language-selector">
<button class="language-button" (click)="toggleDropdown()">
<div class="language-selector" role="listbox">
<button class="language-button" (click)="toggleDropdown()" (keydown)="onKeyDown($event)" aria-haspopup="listbox" [attr.aria-expanded]="dropdownOpen">
<img [src]="languageService.getCurrentLanguage()?.flagSvg"
[alt]="languageService.getCurrentLanguage()?.name"
class="language-flag">
@@ -13,6 +13,8 @@
@for (lang of languageService.languages; track lang.code) {
<button
class="language-option"
role="option"
[attr.aria-selected]="languageService.currentLanguage() === lang.code"
[class.active]="languageService.currentLanguage() === lang.code"
[class.disabled]="!lang.enabled"
[disabled]="!lang.enabled"

View File

@@ -31,6 +31,15 @@ export class LanguageSelectorComponent {
this.dropdownOpen = false;
}
onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.dropdownOpen = false;
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.toggleDropdown();
}
}
@HostListener('document:click', ['$event'])
onClickOutside(event: Event): void {
if (!this.elementRef.nativeElement.contains(event.target)) {

View File

@@ -1,54 +0,0 @@
<div class="region-selector">
<button class="region-trigger" (click)="toggleDropdown()" [class.active]="dropdownOpen()">
<svg class="pin-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<span class="region-name">
@if (detecting()) {
<span class="detecting">...</span>
} @else if (region()) {
{{ region()!.city }}
} @else {
{{ 'location.allRegions' | translate }}
}
</span>
<svg class="chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
[class.rotated]="dropdownOpen()">
<path d="M6 9l6 6 6-6"></path>
</svg>
</button>
@if (dropdownOpen()) {
<div class="region-dropdown">
<div class="dropdown-header">
<span>{{ 'location.chooseRegion' | translate }}</span>
@if (!detecting()) {
<button class="detect-btn" (click)="detectLocation()" title="{{ 'location.detectAuto' | translate }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"></path>
</svg>
</button>
}
</div>
<div class="region-list">
<button class="region-option" [class.selected]="!region()" (click)="selectGlobal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
<span>{{ 'location.allRegions' | translate }}</span>
</button>
@for (r of regions(); track r.id) {
<button class="region-option" [class.selected]="region()?.id === r.id" (click)="selectRegion(r)">
<span class="region-city">{{ r.city }}</span>
<span class="region-country">{{ r.country }}</span>
</button>
}
</div>
</div>
}
</div>

View File

@@ -1,180 +0,0 @@
.region-selector {
position: relative;
}
.region-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
background: transparent;
cursor: pointer;
font-size: 13px;
color: var(--text-primary, #333);
transition: all 0.2s ease;
white-space: nowrap;
&:hover, &.active {
border-color: var(--accent-color, #497671);
background: var(--bg-hover, rgba(73, 118, 113, 0.05));
}
.pin-icon {
flex-shrink: 0;
color: var(--accent-color, #497671);
}
.region-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
.detecting {
animation: pulse 1s ease infinite;
}
}
.chevron {
flex-shrink: 0;
transition: transform 0.2s ease;
&.rotated {
transform: rotate(180deg);
}
}
}
.region-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 220px;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 1000;
overflow: hidden;
animation: slideDown 0.15s ease;
}
.dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detect-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: var(--bg-hover, rgba(73, 118, 113, 0.08));
color: var(--accent-color, #497671);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--accent-color, #497671);
color: #fff;
}
}
.region-list {
max-height: 280px;
overflow-y: auto;
padding: 4px;
}
.region-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
font-size: 14px;
color: var(--text-primary, #333);
text-align: left;
transition: background 0.15s ease;
&:hover {
background: var(--bg-hover, rgba(73, 118, 113, 0.06));
}
&.selected {
background: var(--accent-color, #497671);
color: #fff;
.region-country {
color: rgba(255, 255, 255, 0.7);
}
}
.region-city {
flex: 1;
}
.region-country {
font-size: 12px;
color: var(--text-secondary, #999);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
// Mobile adjustments
@media (max-width: 768px) {
.region-trigger {
padding: 5px 8px;
font-size: 12px;
.region-name {
max-width: 80px;
}
}
.region-dropdown {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
min-width: 100%;
border-radius: 16px 16px 0 0;
max-height: 60vh;
.region-list {
max-height: 50vh;
}
}
}

View File

@@ -1,47 +0,0 @@
import { Component, ChangeDetectionStrategy, inject, signal, HostListener } from '@angular/core';
import { LocationService } from '../../services/location.service';
import { Region } from '../../models/location.model';
import { TranslatePipe } from '../../i18n/translate.pipe';
@Component({
selector: 'app-region-selector',
imports: [TranslatePipe],
templateUrl: './region-selector.component.html',
styleUrls: ['./region-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RegionSelectorComponent {
private locationService = inject(LocationService);
region = this.locationService.region;
regions = this.locationService.regions;
detecting = this.locationService.detecting;
dropdownOpen = signal(false);
toggleDropdown(): void {
this.dropdownOpen.update(v => !v);
}
selectRegion(region: Region): void {
this.locationService.setRegion(region);
this.dropdownOpen.set(false);
}
selectGlobal(): void {
this.locationService.clearRegion();
this.dropdownOpen.set(false);
}
detectLocation(): void {
this.locationService.detectLocation();
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
const target = event.target as HTMLElement;
if (!target.closest('app-region-selector')) {
this.dropdownOpen.set(false);
}
}
}

View File

@@ -1,47 +0,0 @@
@if (showDialog()) {
<div class="login-overlay" (click)="close()">
<div class="login-dialog" (click)="$event.stopPropagation()">
<button class="close-btn" (click)="close()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<div class="login-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
</div>
<h2>{{ 'auth.loginRequired' | translate }}</h2>
<p class="login-desc">{{ 'auth.loginDescription' | translate }}</p>
@if (status() === 'checking') {
<div class="login-status checking">
<div class="spinner"></div>
<span>{{ 'auth.checking' | translate }}</span>
</div>
} @else {
<button class="telegram-btn" (click)="openTelegramLogin()">
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
{{ 'auth.loginWithTelegram' | translate }}
</button>
<div class="qr-section">
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
<div class="qr-container">
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + loginUrl()"
alt="QR Code"
width="180"
height="180"
loading="lazy" />
</div>
</div>
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
}
</div>
</div>
}

View File

@@ -1,184 +0,0 @@
.login-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
padding: 16px;
}
.login-dialog {
position: relative;
background: var(--bg-card, #fff);
border-radius: 20px;
padding: 32px 28px;
max-width: 400px;
width: 100%;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
animation: scaleIn 0.25s ease;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--bg-hover, #f0f0f0);
color: var(--text-secondary, #666);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
background: #e0e0e0;
color: #333;
}
}
.login-icon {
margin: 0 auto 16px;
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--accent-light, rgba(73, 118, 113, 0.1));
color: var(--accent-color, #497671);
display: flex;
align-items: center;
justify-content: center;
}
h2 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary, #1a1a1a);
}
.login-desc {
margin: 0 0 24px;
font-size: 14px;
color: var(--text-secondary, #666);
line-height: 1.5;
}
.telegram-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 24px;
border: none;
border-radius: 12px;
background: #2AABEE;
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #229ED9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
}
&:active {
transform: translateY(0);
}
.tg-icon {
flex-shrink: 0;
}
}
.qr-section {
margin-top: 20px;
.qr-hint {
margin: 0 0 12px;
font-size: 13px;
color: var(--text-secondary, #999);
}
.qr-container {
display: inline-flex;
padding: 12px;
background: #fff;
border-radius: 12px;
border: 1px solid #e8e8e8;
img {
display: block;
border-radius: 4px;
}
}
}
.login-note {
margin: 16px 0 0;
font-size: 12px;
color: var(--text-secondary, #999);
line-height: 1.4;
}
.login-status {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
color: var(--text-secondary, #666);
font-size: 14px;
.spinner {
width: 20px;
height: 20px;
border: 2px solid #e0e0e0;
border-top-color: var(--accent-color, #497671);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 480px) {
.login-dialog {
padding: 24px 20px;
border-radius: 16px;
}
.qr-section .qr-container img {
width: 140px;
height: 140px;
}
}

View File

@@ -1,66 +0,0 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
import { AuthService } from '../../services/auth.service';
import { TranslatePipe } from '../../i18n/translate.pipe';
@Component({
selector: 'app-telegram-login',
imports: [TranslatePipe],
templateUrl: './telegram-login.component.html',
styleUrls: ['./telegram-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TelegramLoginComponent implements OnInit, OnDestroy {
private authService = inject(AuthService);
showDialog = this.authService.showLoginDialog;
status = this.authService.status;
loginUrl = signal('');
private pollTimer?: ReturnType<typeof setInterval>;
ngOnInit(): void {
this.loginUrl.set(this.authService.getTelegramLoginUrl());
}
ngOnDestroy(): void {
this.stopPolling();
}
close(): void {
this.authService.hideLogin();
this.stopPolling();
}
/** Open Telegram login link and start polling for session */
openTelegramLogin(): void {
window.open(this.loginUrl(), '_blank');
this.startPolling();
}
/** Start polling the backend to detect when user completes Telegram auth */
private startPolling(): void {
this.stopPolling();
// Check every 3 seconds for up to 5 minutes
let checks = 0;
this.pollTimer = setInterval(() => {
checks++;
if (checks > 100) { // 100 * 3s = 5 min
this.stopPolling();
return;
}
this.authService.checkSession();
// If authenticated, stop polling and close dialog
if (this.authService.isAuthenticated()) {
this.stopPolling();
this.authService.hideLogin();
}
}, 3000);
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = undefined;
}
}
}

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 = 20;
// 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

@@ -148,7 +148,6 @@ export const en: Translations = {
mediumStock: 'Running low',
addToCart: 'Add to cart',
description: 'Description',
specifications: 'Specifications',
reviews: 'Reviews',
yourReview: 'Your review',
leaveReview: 'Leave a review',
@@ -186,17 +185,4 @@ export const en: Translations = {
retry: 'Try again',
loading: 'Loading...',
},
location: {
allRegions: 'All regions',
chooseRegion: 'Choose region',
detectAuto: 'Detect automatically',
},
auth: {
loginRequired: 'Login required',
loginDescription: 'Please log in via Telegram to proceed with your order',
checking: 'Checking...',
loginWithTelegram: 'Log in with Telegram',
orScanQr: 'Or scan the QR code',
loginNote: 'You will be redirected back after login',
},
};

View File

@@ -2,202 +2,187 @@ import { Translations } from './translations';
export const hy: Translations = {
header: {
home: '╘│╒м╒н╒б╒╛╒╕╓А',
search: '╒И╓А╒╕╒╢╒╕╓В╒┤',
about: '╒Д╒е╓А ╒┤╒б╒╜╒л╒╢',
contacts: '╘┐╒б╒║',
searchPlaceholder: '╒И╓А╒╕╒╢╒е╒м...',
catalog: '╘┐╒б╒┐╒б╒м╒╕╒г',
home: 'Գլխավոր',
search: 'Որոնում',
about: 'Մեր մասին',
contacts: 'Կապ',
searchPlaceholder: 'Որոնել...',
catalog: 'Կատալոգ',
},
footer: {
description: '╘║╒б╒┤╒б╒╢╒б╒п╒б╒п╒л╓Б ╒┤╒б╓А╓Д╒е╒й╓Г╒м╒е╒╡╒╜ ╒░╒б╓А╒┤╒б╓А ╒г╒╢╒╕╓В╒┤╒╢╒е╓А╒л ╒░╒б╒┤╒б╓А',
company: '╘╕╒╢╒п╒е╓А╒╕╓В╒й╒╡╒╕╓В╒╢',
aboutUs: '╒Д╒е╓А ╒┤╒б╒╜╒л╒╢',
contacts: '╘┐╒б╒║',
requisites: '╒О╒б╒╛╒е╓А╒б╒║╒б╒╡╒┤╒б╒╢╒╢╒е╓А',
support: '╘▒╒╗╒б╒п╓Б╒╕╓В╒й╒╡╒╕╓В╒╢',
faq: '╒А╒П╒А',
delivery: '╘▒╒╝╒б╓Д╒╕╓В╒┤',
guarantee: '╘╡╓А╒б╒╖╒н╒л╓Д',
legal: '╘╗╓А╒б╒╛╒б╒п╒б╒╢ ╒┐╒е╒▓╒е╒п╒б╒┐╒╛╒╕╓В╒й╒╡╒╕╓В╒╢',
offer: '╒Х╓Ж╒е╓А╒┐╒б',
privacy: '╘│╒б╒▓╒┐╒╢╒л╒╕╓В╒й╒╡╒╕╓В╒╢',
returns: '╒О╒е╓А╒б╒д╒б╓А╒▒',
info: '╒П╒е╒▓╒е╒п╒б╒┐╒╛╒╕╓В╒й╒╡╒╕╓В╒╢',
aboutCompany: '╘╕╒╢╒п╒е╓А╒╕╓В╒й╒╡╒б╒╢ ╒┤╒б╒╜╒л╒╢',
documents: '╒У╒б╒╜╒┐╒б╒й╒▓╒й╒е╓А',
paymentRules: '╒О╒│╒б╓А╒┤╒б╒╢ ╒п╒б╒╢╒╕╒╢╒╢╒е╓А',
returnPolicy: '╒О╒е╓А╒б╒д╒б╓А╒▒╒л ╓Д╒б╒▓╒б╓Д╒б╒п╒б╒╢╒╕╓В╒й╒╡╒╕╓В╒╢',
publicOffer: '╒А╒б╒╢╓А╒б╒╡╒л╒╢ ╓Е╓Ж╒е╓А╒┐╒б',
help: '╒Х╒г╒╢╒╕╓В╒й╒╡╒╕╓В╒╢',
payment: '╒О╒│╒б╓А╒╕╓В╒┤',
allRightsReserved: '╘▓╒╕╒м╒╕╓А ╒л╓А╒б╒╛╒╕╓В╒╢╓Д╒╢╒е╓А╒и ╒║╒б╒╖╒┐╒║╒б╒╢╒╛╒б╒о ╒е╒╢╓Й',
description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար',
company: 'Ընկերություն',
aboutUs: 'Մեր մասին',
contacts: 'Կապ',
requisites: 'Վավերապայմաններ',
support: 'Աջակցություն',
faq: 'ՀՏՀ',
delivery: 'Առաքում',
guarantee: 'Երաշխիք',
legal: 'Իրավական տեղեկատվություն',
offer: 'Օֆերտա',
privacy: 'Գաղտնիություն',
returns: 'Վերադարձ',
info: 'Տեղեկատվություն',
aboutCompany: 'Ընկերության մասին',
documents: 'Փաստաթղթեր',
paymentRules: 'Վճարման կանոններ',
returnPolicy: 'Վերադարձի քաղաքականություն',
publicOffer: 'Հանրային օֆերտա',
help: 'Օգնություն',
payment: 'Վճարում',
allRightsReserved: 'Բոլոր իրավունքները պաշտպանված են։',
},
home: {
welcomeTo: '╘▓╒б╓А╒л ╒г╒б╒м╒╕╓В╒╜╒┐ {{brand}}',
subtitle: '╘│╒┐╒е╓Д ╒б╒┤╒е╒╢ ╒л╒╢╒╣ ╒┤╒е╒п ╒╛╒б╒╡╓А╒╕╓В╒┤',
startSearch: '╒Н╒п╒╜╒е╒м ╒╕╓А╒╕╒╢╒╕╓В╒┤╒и',
loading: '╘┐╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒е╒╢...',
errorTitle: '╘╗╒╢╒╣-╒╕╓А ╒в╒б╒╢ ╒╜╒н╒б╒м ╒з ╒г╒╢╒б╓Б╒е╒м',
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
categoriesTitle: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒л ╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А',
categoriesSubtitle: '╘╕╒╢╒┐╓А╒е╓Д ╒░╒е╒┐╒б╓Д╓А╓Д╓А╒╕╒▓ ╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢',
categoriesEmpty: '╘┐╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А╒и ╒╖╒╕╓В╒┐╒╕╒╛ ╒п╒░╒б╒╡╒┐╒╢╒╛╒е╒╢',
categoriesEmptyDesc: '╒Д╒е╒╢╓Д ╒б╒╖╒н╒б╒┐╒╕╓В╒┤ ╒е╒╢╓Д ╒п╒б╒┐╒б╒м╒╕╒г╒л ╒░╒б╒┤╒б╒м╓А╒┤╒б╒╢ ╒╛╓А╒б',
dexarHeroTitle: '╘▒╒╡╒╜╒┐╒е╒▓ ╒д╒╕╓В ╒п╒г╒┐╒╢╒е╒╜ ╒б╒┤╒е╒╢ ╒л╒╢╒╣',
dexarHeroSubtitle: '╒А╒б╒ж╒б╓А╒б╒╛╒╕╓А ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А ╒┤╒е╒п ╒╛╒б╒╡╓А╒╕╓В╒┤',
dexarHeroTagline: '╒║╒б╓А╒ж ╓З ╒░╒б╓А╒┤╒б╓А',
goToCatalog: '╘▒╒╢╓Б╒╢╒е╒м ╒п╒б╒┐╒б╒м╒╕╒г',
findProduct: '╘│╒┐╒╢╒е╒м ╒б╒║╓А╒б╒╢╓Д',
loadingDexar: '╘┐╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒е╒╢...',
catalogTitle: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒л ╒п╒б╒┐╒б╒м╒╕╒г',
emptyCategoriesDexar: '╘┐╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А╒и ╒д╒е╒╝ ╒╣╒п╒б╒╢',
categoriesSoonDexar: '╒З╒╕╓В╒┐╒╕╒╛ ╒б╒╡╒╜╒┐╒е╒▓ ╒п╒░╒б╒╡╒┐╒╢╒╛╒е╒╢ ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А╒л ╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А',
itemsCount: '{{count}} ╒б╒║╓А╒б╒╢╓Д',
welcomeTo: 'Բարի գալուստ {{brand}}',
subtitle: 'Գտեք ամեն ինչ մեկ վայրում',
startSearch: 'Սկսել որոնումը',
loading: 'Կատեգորիաները բեռնվում են...',
errorTitle: 'Ինչ-որ բան սխալ է գնացել',
retry: 'Փորձել կրկին',
categoriesTitle: 'Ապրանքների կատեգորիաներ',
categoriesSubtitle: 'Ընտրեք հետաքրքրող կատեգորիան',
categoriesEmpty: 'Կատեգորիաները շուտով կհայտնվեն',
categoriesEmptyDesc: 'Մենք աշխատում ենք կատալոգի համալրման վրա',
dexarHeroTitle: 'Այստեղ դու կգտնես ամեն ինչ',
dexarHeroSubtitle: 'Հազարավոր ապրանքներ մեկ վայրում',
dexarHeroTagline: 'պարզ և հարմար',
goToCatalog: 'Անցնել կատալոգ',
findProduct: 'Գտնել ապրանք',
loadingDexar: 'Կատեգորիաները բեռնվում են...',
catalogTitle: 'Ապրանքների կատալոգ',
emptyCategoriesDexar: 'Կատեգորիաները դեռ չկան',
categoriesSoonDexar: 'Շուտով այստեղ կհայտնվեն ապրանքների կատեգորիաներ',
itemsCount: '{{count}} ապրանք',
},
cart: {
title: '╘╢╒б╒┤╒в╒╡╒╕╓В╒▓',
clear: '╒Д╒б╓Д╓А╒е╒м',
empty: '╘╢╒б╒┤╒в╒╡╒╕╓В╒▓╒и ╒д╒б╒┐╒б╓А╒п ╒з',
emptyDesc: '╘▒╒╛╒е╒м╒б╓Б╓А╒е╓Д ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А ╒г╒╢╒╕╓В╒┤╒╢╒е╓А╒и ╒╜╒п╒╜╒е╒м╒╕╓В ╒░╒б╒┤╒б╓А',
goShopping: '╘▒╒╢╓Б╒╢╒е╒м ╒г╒╢╒╕╓В╒┤╒╢╒е╓А╒л',
total: '╘╕╒╢╒д╒б╒┤╒е╒╢╒и',
items: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А',
deliveryLabel: '╘▒╒╝╒б╓Д╒╕╓В╒┤',
toPay: '╒О╒│╒б╓А╒┤╒б╒╢ ╒е╒╢╒й╒б╒п╒б',
agreeWith: '╘╡╒╜ ╒░╒б╒┤╒б╒▒╒б╒╡╒╢ ╒е╒┤',
publicOffer: '╒░╒б╒╢╓А╒б╒╡╒л╒╢ ╓Е╓Ж╒е╓А╒┐╒б╒╡╒л╒╢',
returnPolicy: '╒╛╒е╓А╒б╒д╒б╓А╒▒╒л ╓Д╒б╒▓╒б╓Д╒б╒п╒б╒╢╒╕╓В╒й╒╡╒б╒╢╒и',
guaranteeTerms: '╒е╓А╒б╒╖╒н╒л╓Д╒б╒╡╒л╒╢ ╒║╒б╒╡╒┤╒б╒╢╒╢╒е╓А╒л╒╢',
privacyPolicy: '╒г╒б╒▓╒┐╒╢╒л╒╕╓В╒й╒╡╒б╒╢ ╓Д╒б╒▓╒б╓Д╒б╒п╒б╒╢╒╕╓В╒й╒╡╒б╒╢╒и',
and: '╓З',
checkout: '╒Б╓З╒б╒п╒е╓А╒║╒е╒м ╒║╒б╒┐╒╛╒е╓А',
close: '╒У╒б╒п╒е╒м',
creatingPayment: '╒О╒│╒б╓А╒╕╓В╒┤╒и ╒╜╒┐╒е╒▓╒о╒╛╒╕╓В╒┤ ╒з...',
waitFewSeconds: '╒Н╒║╒б╒╜╒е╓Д ╒┤╒л ╓Д╒б╒╢╒л ╒╛╒б╒╡╓А╒п╒╡╒б╒╢',
scanQr: '╒Н╒п╒б╒╢╒б╒╛╒╕╓А╒е╓Д QR ╒п╒╕╒д╒и ╒╛╒│╒б╓А╒┤╒б╒╢ ╒░╒б╒┤╒б╓А',
amountToPay: '╒О╒│╒б╓А╒┤╒б╒╢ ╒г╒╕╓В╒┤╒б╓А╒и╒Э',
waitingPayment: '╒Н╒║╒б╒╜╒╕╓В╒┤ ╒е╒╢╓Д ╒╛╒│╒б╓А╒┤╒б╒╢╒и...',
copied: 'тЬУ ╒К╒б╒┐╒│╒е╒╢╒╛╒б╒о ╒з',
copyLink: '╒К╒б╒┐╒│╒е╒╢╒е╒м ╒░╒▓╒╕╓В╒┤╒и',
openNewTab: '╘▓╒б╓Б╒е╒м ╒╢╒╕╓А ╒╢╒е╓А╒д╒л╓А╒╕╓В╒┤',
paymentSuccess: '╒З╒╢╒╕╓А╒░╒б╒╛╒╕╓А╒╕╓В╒┤ ╒е╒╢╓Д╓Й ╒О╒│╒б╓А╒╕╓В╒┤╒и ╒░╒б╒╗╒╕╒▓╒╕╓В╒й╒╡╒б╒┤╒в ╒п╒б╒┐╒б╓А╒╛╒е╒м ╒з╓Й',
paymentSuccessDesc: '╒Д╒╕╓В╒┐╓Д╒б╒г╓А╒е╓Д ╒▒╒е╓А ╒п╒╕╒╢╒┐╒б╒п╒┐╒б╒╡╒л╒╢ ╒┐╒╛╒╡╒б╒м╒╢╒е╓А╒и, ╓З ╒┤╒е╒╢╓Д ╒п╒╕╓В╒▓╒б╓А╒п╒е╒╢╓Д ╒г╒╢╒╕╓В╒┤╒и ╒┤╒л ╓Д╒б╒╢╒л ╓А╒╕╒║╒е╒л ╒и╒╢╒й╒б╓Б╓Д╒╕╓В╒┤',
sending: '╒И╓В╒▓╒б╓А╒п╒╛╒╕╓В╒┤ ╒з...',
send: '╒И╓В╒▓╒б╓А╒п╒е╒м',
paymentTimeout: '╒Н╒║╒б╒╜╒┤╒б╒╢ ╒к╒б╒┤╒б╒╢╒б╒п╒и ╒╜╒║╒б╒╝╒╛╒е╒м ╒з',
paymentTimeoutDesc: '╒Д╒е╒╢╓Д ╒╣╒е╒╢╓Д ╒╜╒┐╒б╓Б╒е╒м ╒╛╒│╒б╓А╒┤╒б╒╢ ╒░╒б╒╜╒┐╒б╒┐╒╕╓В╒┤ 3 ╓А╒╕╒║╒е╒л ╒и╒╢╒й╒б╓Б╓Д╒╕╓В╒┤╓Й',
autoClose: '╒К╒б╒┐╒╕╓В╒░╒б╒╢╒и ╒п╓Г╒б╒п╒╛╒л ╒б╒╛╒┐╒╕╒┤╒б╒┐...',
confirmClear: '╒А╒б╒┤╒╕╒ж╒╛╒б╒Ю╒о ╒е╓Д, ╒╕╓А ╓Б╒б╒╢╒п╒б╒╢╒╕╓В╒┤ ╒е╓Д ╒┤╒б╓Д╓А╒е╒м ╒ж╒б╒┤╒в╒╡╒╕╓В╒▓╒и╓Й',
acceptTerms: '╘╜╒╢╒д╓А╒╕╓В╒┤ ╒е╒╢╓Д ╒и╒╢╒д╒╕╓В╒╢╒е╒м ╓Е╓Ж╒е╓А╒┐╒б╒╡╒л, ╒╛╒е╓А╒б╒д╒б╓А╒▒╒л ╓З ╒е╓А╒б╒╖╒н╒л╓Д╒л ╒║╒б╒╡╒┤╒б╒╢╒╢╒е╓А╒и ╒║╒б╒┐╒╛╒е╓А╒и ╒░╒б╒╜╒┐╒б╒┐╒е╒м╒╕╓В ╒░╒б╒┤╒б╓А╓Й',
copyError: '╒К╒б╒┐╒│╒е╒╢╒┤╒б╒╢ ╒╜╒н╒б╒м╒Э',
emailSuccess: 'Email-╒и ╒░╒б╒╗╒╕╒▓╒╕╓В╒й╒╡╒б╒┤╒в ╒╕╓В╒▓╒б╓А╒п╒╛╒е╒м ╒з╓Й ╒Н╒┐╒╕╓В╒г╒е╓Д ╒▒╒е╓А ╓Г╒╕╒╜╒┐╒и╓Й',
emailError: 'Email ╒╕╓В╒▓╒б╓А╒п╒е╒м╒╕╓В ╒к╒б╒┤╒б╒╢╒б╒п ╒┐╒е╒▓╒л ╒╕╓В╒╢╒е╓Б╒б╒╛ ╒╜╒н╒б╒м╓Й ╘╜╒╢╒д╓А╒╕╓В╒┤ ╒е╒╢╓Д ╓Г╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢╓Й',
phoneRequired: '╒А╒е╒╝╒б╒н╒╕╒╜╒б╒░╒б╒┤╒б╓А╒и ╒║╒б╓А╒┐╒б╒д╒л╓А ╒з',
phoneMoreDigits: '╒Д╒╕╓В╒┐╓Д╒б╒г╓А╒е╓Д ╓З╒╜ {{count}} ╒й╒л╒╛',
phoneTooMany: '╒Й╒б╓Г╒б╒ж╒б╒╢╓Б ╒╖╒б╒┐ ╒й╒╛╒е╓А',
emailRequired: 'Email-╒и ╒║╒б╓А╒┐╒б╒д╒л╓А ╒з',
emailTooShort: 'Email-╒и ╒╣╒б╓Г╒б╒ж╒б╒╢╓Б ╒п╒б╓А╒│ ╒з (╒╢╒╛╒б╒ж╒б╒г╒╕╓В╒╡╒╢╒и 5 ╒╢╒л╒╖)',
emailTooLong: 'Email-╒и ╒╣╒б╓Г╒б╒ж╒б╒╢╓Б ╒е╓А╒п╒б╓А ╒з (╒б╒╝╒б╒╛╒е╒м╒б╒г╒╕╓В╒╡╒╢╒и 100 ╒╢╒л╒╖)',
emailNeedsAt: 'Email-╒и ╒║╒е╒┐╓Д ╒з ╒║╒б╓А╒╕╓В╒╢╒б╒п╒л @ ╒╢╒╖╒б╒╢╒и',
emailNeedsDomain: 'Email-╒и ╒║╒е╒┐╓Д ╒з ╒║╒б╓А╒╕╓В╒╢╒б╒п╒л ╒д╒╕╒┤╒е╒╢ (.com, .ru ╓З ╒б╒╡╒м╒╢)',
emailInvalid: 'Email-╒л ╒▒╓З╒б╒╣╒б╓Г╒и ╒╜╒н╒б╒м ╒з',
title: 'Զամբյուղ',
clear: 'Մաքրել',
empty: 'Զամբյուղը դատարկ է',
emptyDesc: 'Ավելացրեք ապրանքներ գնումները սկսելու համար',
goShopping: 'Անցնել գնումների',
total: 'Ընդամենը',
items: 'Ապրանքներ',
deliveryLabel: 'Առաքում',
toPay: 'Վճարման ենթակա',
agreeWith: 'Ես համաձայն եմ',
publicOffer: 'հանրային օֆերտային',
returnPolicy: 'վերադարձի քաղաքականությանը',
guaranteeTerms: 'երաշխիքային պայմաններին',
privacyPolicy: 'գաղտնիության քաղաքականությանը',
and: 'և',
checkout: 'Ձևակերպել պատվեր',
close: 'Փակել',
creatingPayment: 'Վճարումը ստեղծվում է...',
waitFewSeconds: 'Սպասեք մի քանի վայրկյան',
scanQr: 'Սկանավորեք QR կոդը վճարման համար',
amountToPay: 'Վճարման գումարը՝',
waitingPayment: 'Սպասում ենք վճարմանը...',
copied: '✓ Պատճենված է',
copyLink: 'Պատճենել հղումը',
openNewTab: 'Բացել նոր ներդիրում',
paymentSuccess: 'Շնորհավորում ենք։ Վճարումը հաջողությամբ կատարվել է։',
paymentSuccessDesc: 'Մուտքագրեք ձեր կոնտակտային տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում',
sending: 'Ուղարկվում է...',
send: 'Ուղարկել',
paymentTimeout: 'Սպասման ժամանակը սպառվել է',
paymentTimeoutDesc: 'Մենք չենք ստացել վճարման հաստատում 3 րոպեի ընթացքում։',
autoClose: 'Պատուհանը կփակվի ավտոմատ...',
confirmClear: 'Համոզվա՞ծ եք, որ ցանկանում եք մաքրել զամբյուղը։',
acceptTerms: 'Խնդրում ենք ընդունել օֆերտայի, վերադարձի և երաշխիքի պայմանները պատվերը հաստատելու համար։',
copyError: 'Պատճենման սխալ՝',
emailSuccess: 'Email-ը հաջողությամբ ուղարկվել է։ Ստուգեք ձեր փոստը։',
emailError: 'Email ուղարկելու ժամանակ տեղի ունեցավ սխալ։ Խնդրում ենք փորձել կրկին։',
phoneRequired: 'Հեռախոսահամարը պարտադիր է',
phoneMoreDigits: 'Մուտքագրեք ևս {{count}} թիվ',
phoneTooMany: 'Չափազանց շատ թվեր',
emailRequired: 'Email-ը պարտադիր է',
emailTooShort: 'Email-ը չափազանց կարճ է (նվազագույնը 5 նիշ)',
emailTooLong: 'Email-ը չափազանց երկար է (առավելագույնը 100 նիշ)',
emailNeedsAt: 'Email-ը պետք է պարունակի @ նշանը',
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեն (.com, .ru և այլն)',
emailInvalid: 'Email-ի ձևաչափը սխալ է',
},
search: {
title: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒л ╒╕╓А╒╕╒╢╒╕╓В╒┤',
placeholder: '╒Д╒╕╓В╒┐╓Д╒б╒г╓А╒е╓Д ╒б╒║╓А╒б╒╢╓Д╒л ╒б╒╢╒╕╓В╒╢╒и...',
resultsCount: '╘│╒┐╒╢╒╛╒б╒о ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А╒Э',
searching: '╒И╓А╒╕╒╢╒╕╓В╒┤...',
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
noResults: '╒И╒╣╒л╒╢╒╣ ╒╣╒л ╒г╒┐╒╢╒╛╒е╒м',
noResultsFor: '"{{query}}" ╒░╒б╓А╓Б╒┤╒б╒╢ ╒░╒б╒┤╒б╓А ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А ╒╣╒е╒╢ ╒г╒┐╒╢╒╛╒е╒м',
noResultsHint: '╒У╒╕╓А╒▒╒е╓Д ╓Г╒╕╒н╒е╒м ╒░╒б╓А╓Б╒╕╓В╒┤╒и ╒п╒б╒┤ ╓Е╒г╒┐╒б╒г╒╕╓А╒о╒е╒м ╒б╒╡╒м ╒в╒б╒╢╒б╒м╒л ╒в╒б╒╝╒е╓А',
addToCart: '╘▒╒╛╒е╒м╒б╓Б╒╢╒е╒м ╒ж╒б╒┤╒в╒╡╒╕╓В╒▓',
loadingMore: '╘▓╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
allLoaded: '╘▓╒╕╒м╒╕╓А ╒б╓А╒д╒╡╒╕╓В╒╢╓Д╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒б╒о ╒е╒╢',
emptyState: '╒Д╒╕╓В╒┐╓Д╒б╒г╓А╒е╓Д ╒░╒б╓А╓Б╒╕╓В╒┤ ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А ╒╕╓А╒╕╒╢╒е╒м╒╕╓В ╒░╒б╒┤╒б╓А',
of: '╒л╓Б',
title: 'Ապրանքների որոնում',
placeholder: 'Մուտքագրեք ապրանքի անունը...',
resultsCount: 'Գտնված ապրանքներ՝',
searching: 'Որոնում...',
retry: 'Փորձել կրկին',
noResults: 'Ոչինչ չի գտնվել',
noResultsFor: '"{{query}}" հարցման համար ապրանքներ չեն գտնվել',
noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր',
addToCart: 'Ավելացնել զամբյուղ',
loadingMore: 'Բեռնվում է...',
allLoaded: 'Բոլոր արդյունքները բեռնված են',
emptyState: 'Մուտքագրեք հարցում ապրանքներ որոնելու համար',
of: 'ից',
},
category: {
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
addToCart: '╘▒╒╛╒е╒м╒б╓Б╒╢╒е╒м ╒ж╒б╒┤╒в╒╡╒╕╓В╒▓',
loadingMore: '╘▓╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
allLoaded: '╘▓╒╕╒м╒╕╓А ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒б╒о ╒е╒╢',
emptyTitle: '╒И╓В╒║╒╜╓Й ╘▒╒╡╒╜╒┐╒е╒▓ ╒д╒е╒╝ ╒д╒б╒┐╒б╓А╒п ╒з',
emptyDesc: '╘▒╒╡╒╜ ╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╡╒╕╓В╒┤ ╒д╒е╒╝ ╒б╒║╓А╒б╒╢╓Д╒╢╒е╓А ╒╣╒п╒б╒╢, ╒в╒б╒╡╓Б ╒╖╒╕╓В╒┐╒╕╒╛ ╒п╒░╒б╒╡╒┐╒╢╒╛╒е╒╢',
goHome: '╘│╒м╒н╒б╒╛╒╕╓А ╒з╒╗',
loading: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒е╒╢...',
retry: 'Փորձել կրկին',
addToCart: 'Ավելացնել զամբյուղ',
loadingMore: 'Բեռնվում է...',
allLoaded: 'Բոլոր ապրանքները բեռնված են',
emptyTitle: 'Ուպս։ Այստեղ դեռ դատարկ է',
emptyDesc: 'Այս կատեգորիայում դեռ ապրանքներ չկան, բայց շուտով կհայտնվեն',
goHome: 'Գլխավոր էջ',
loading: 'Ապրանքները բեռնվում են...',
},
subcategories: {
loading: '╘╡╒╢╒й╒б╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒е╒╢...',
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
emptyTitle: '╒И╓В╒║╒╜╓Й ╘╡╒╢╒й╒б╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А ╒д╒е╒╝ ╒╣╒п╒б╒╢',
emptyDesc: '╘▒╒╡╒╜ ╒в╒б╒к╒╢╒╕╓В╒┤ ╒д╒е╒╝ ╒е╒╢╒й╒б╒п╒б╒┐╒е╒г╒╕╓А╒л╒б╒╢╒е╓А ╒╣╒п╒б╒╢, ╒в╒б╒╡╓Б ╒╖╒╕╓В╒┐╒╕╒╛ ╒п╒░╒б╒╡╒┐╒╢╒╛╒е╒╢',
goHome: '╘│╒м╒н╒б╒╛╒╕╓А ╒з╒╗',
loading: 'Ենթակատեգորիաները բեռնվում են...',
retry: 'Փորձել կրկին',
emptyTitle: 'Ուպս։ Ենթակատեգորիաներ դեռ չկան',
emptyDesc: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան, բայց շուտով կհայտնվեն',
goHome: 'Գլխավոր էջ',
},
itemDetail: {
loading: '╘▓╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
loadingDexar: '╘▒╒║╓А╒б╒╢╓Д╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
back: '╒О╒е╓А╒б╒д╒б╒╝╒╢╒б╒м',
backHome: '╒О╒е╓А╒б╒д╒б╒╝╒╢╒б╒м ╒г╒м╒н╒б╒╛╒╕╓А ╒з╒╗',
noImage: '╒К╒б╒┐╒п╒е╓А ╒╣╒п╒б',
stock: '╘▒╒╝╒п╒б╒╡╒╕╓В╒й╒╡╒╕╓В╒╢╒Э',
inStock: '╘▒╒╝╒п╒б ╒з',
lowStock: '╒Д╒╢╒б╓Б╒е╒м ╒з ╓Д╒л╒╣',
lastItems: '╒О╒е╓А╒╗╒л╒╢ ╒░╒б╒┐╒е╓А╒и',
mediumStock: '╒О╒е╓А╒╗╒б╒╢╒╕╓В╒┤ ╒з',
addToCart: '╘▒╒╛╒е╒м╒б╓Б╒╢╒е╒м ╒ж╒б╒┤╒в╒╡╒╕╓В╒▓',
description: '╒Ж╒п╒б╓А╒б╒г╓А╒╕╓В╒й╒╡╒╕╓В╒╢',
specifications: 'u{0532}u{0576}u{0578}u{0582}u{0569}u{0561}u{0563}u{0580}u{0565}u{0580}',
reviews: '╘┐╒б╓А╒о╒л╓Д╒╢╒е╓А',
yourReview: '╒Б╒е╓А ╒п╒б╓А╒о╒л╓Д╒и',
leaveReview: '╘╣╒╕╒▓╒╢╒е╒м ╒п╒б╓А╒о╒л╓Д',
rating: '╘│╒╢╒б╒░╒б╒┐╒б╒п╒б╒╢╒Э',
reviewPlaceholder: '╘┐╒л╒╜╒╛╒е╓Д ╒▒╒е╓А ╒┐╒║╒б╒╛╒╕╓А╒╕╓В╒й╒╡╒╕╓В╒╢╒╢╒е╓А╒╕╒╛ ╒б╒║╓А╒б╒╢╓Д╒л ╒┤╒б╒╜╒л╒╢...',
reviewPlaceholderDexar: '╘┐╒л╒╜╒╛╒е╓Д ╒▒╒е╓А ╒┐╒║╒б╒╛╒╕╓А╒╕╓В╒й╒╡╒╕╓В╒╢╒╢╒е╓А╒╕╒╛...',
anonymous: '╘▒╒╢╒б╒╢╒╕╓В╒╢',
submitting: '╒И╓В╒▓╒б╓А╒п╒╛╒╕╓В╒┤ ╒з...',
submit: '╒И╓В╒▓╒б╓А╒п╒е╒м',
reviewSuccess: '╒З╒╢╒╕╓А╒░╒б╒п╒б╒м╒╕╓В╒й╒╡╒╕╓В╒╢ ╒▒╒е╓А ╒п╒б╓А╒о╒л╓Д╒л ╒░╒б╒┤╒б╓А╓Й',
reviewError: '╒И╓В╒▓╒б╓А╒п╒┤╒б╒╢ ╒╜╒н╒б╒м╓Й ╒У╒╕╓А╒▒╒е╓Д ╒б╒╛╒е╒м╒л ╒╕╓В╒╖╓Й',
defaultUser: '╒Х╒г╒┐╒б╒┐╒е╓А',
defaultUserDexar: '╘▒╒╢╒б╒╢╒╕╓В╒╢',
noReviews: '╘┤╒е╒╝ ╒п╒б╓А╒о╒л╓Д╒╢╒е╓А ╒╣╒п╒б╒╢╓Й ╘┤╒б╓А╒▒╒е╓Д ╒б╒╝╒б╒╗╒л╒╢╒и╓Й',
qna: '╒А╒б╓А╓Б╒е╓А ╓З ╒║╒б╒┐╒б╒╜╒н╒б╒╢╒╢╒е╓А',
photo: '╘╝╒╕╓В╒╜╒б╒╢╒п╒б╓А',
reviewsCount: '╒п╒б╓А╒о╒л╓Д',
today: '╘▒╒╡╒╜╓Е╓А',
yesterday: '╘╡╓А╒е╒п',
daysAgo: '╓Е╓А ╒б╒╝╒б╒╗',
weeksAgo: '╒╖╒б╒в╒б╒й ╒б╒╝╒б╒╗',
loading: 'Բեռնվում է...',
loadingDexar: 'Ապրանքը բեռնվում է...',
back: 'Վերադառնալ',
backHome: 'Վերադառնալ գլխավոր էջ',
noImage: 'Պատկեր չկա',
stock: 'Առկայություն՝',
inStock: 'Առկա է',
lowStock: 'Մնացել է քիչ',
lastItems: 'Վերջին հատերը',
mediumStock: 'Վերջանում է',
addToCart: 'Ավելացնել զամբյուղ',
description: 'Նկարագրություն',
reviews: 'Կարծիքներ',
yourReview: 'Ձեր կարծիքը',
leaveReview: 'Թողնել կարծիք',
rating: 'Գնահատական՝',
reviewPlaceholder: 'Կիսվեք ձեր տպավորություններով ապրանքի մասին...',
reviewPlaceholderDexar: 'Կիսվեք ձեր տպավորություններով...',
anonymous: 'Անանուն',
submitting: 'Ուղարկվում է...',
submit: 'Ուղարկել',
reviewSuccess: 'Շնորհակալություն ձեր կարծիքի համար։',
reviewError: 'Ուղարկման սխալ։ Փորձեք ավելի ուշ։',
defaultUser: 'Օգտատեր',
defaultUserDexar: 'Անանուն',
noReviews: 'Դեռ կարծիքներ չկան։ Դարձեք առաջինը։',
qna: 'Հարցեր և պատասխաններ',
photo: 'Լուսանկար',
reviewsCount: 'կարծիք',
today: 'Այսօր',
yesterday: 'Երեկ',
daysAgo: 'օր առաջ',
weeksAgo: 'շաբաթ առաջ',
},
app: {
connecting: '╒Д╒л╒б╓Б╒╕╓В╒┤ ╒╜╒е╓А╒╛╒е╓А╒л╒╢...',
serverUnavailable: '╒Н╒е╓А╒╛╒е╓А╒и ╒░╒б╒╜╒б╒╢╒е╒м╒л ╒╣╒з',
serverError: '╒Й╒░╒б╒╗╒╕╒▓╒╛╒е╓Б ╒┤╒л╒б╒╢╒б╒м ╒╜╒е╓А╒╛╒е╓А╒л╒╢╓Й ╒Н╒┐╒╕╓В╒г╒е╓Д ╒л╒╢╒┐╒е╓А╒╢╒е╒┐ ╒п╒б╒║╒и╓Й',
retryConnection: '╘┐╓А╒п╒╢╒е╒м ╓Г╒╕╓А╒▒╒и',
pageTitle: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒л ╓З ╒о╒б╒╝╒б╒╡╒╕╓В╒й╒╡╒╕╓В╒╢╒╢╒е╓А╒л ╒┤╒б╓А╓Д╒е╒й╓Г╒м╒е╒╡╒╜',
connecting: 'Միացում սերվերին...',
serverUnavailable: 'Սերվերը հասանելի չէ',
serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետ կապը։',
retryConnection: 'Կրկնել փորձը',
pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս',
},
carousel: {
loading: '╘▒╒║╓А╒б╒╢╓Д╒╢╒е╓А╒и ╒в╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒е╒╢...',
addToCart: '╘▒╒╛╒е╒м╒б╓Б╒╢╒е╒м ╒ж╒б╒┤╒в╒╡╒╕╓В╒▓',
loading: 'Ապրանքները բեռնվում են...',
addToCart: 'Ավելացնել զամբյուղ',
},
common: {
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
loading: '╘▓╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
},
location: {
allRegions: 'Բոլոր տարածաշրջաններ',
chooseRegion: 'Ընտրեք տարածաշրջան',
detectAuto: 'Որոշել ինքնաշխատ',
},
auth: {
loginRequired: 'Մուտք պահանջվում է',
loginDescription: 'Պատվերի կատարման համար մուտք արեք Telegram-ի միջոցով',
checking: 'Ստուգում է...',
loginWithTelegram: 'Մուտք գործել Telegram-ով',
orScanQr: 'Կամ սկանավորեք QR կոդը',
loginNote: 'Մուտքից հետո դուք կվերադառնավեք',
retry: 'Փորձել կրկին',
loading: 'Բեռնվում է...',
},
};

View File

@@ -148,7 +148,6 @@ export const ru: Translations = {
mediumStock: 'Заканчивается',
addToCart: 'Добавить в корзину',
description: 'Описание',
specifications: 'Характеристики',
reviews: 'Отзывы',
yourReview: 'Ваш отзыв',
leaveReview: 'Оставить отзыв',
@@ -186,17 +185,4 @@ export const ru: Translations = {
retry: 'Попробовать снова',
loading: 'Загрузка...',
},
location: {
allRegions: 'Все регионы',
chooseRegion: 'Выберите регион',
detectAuto: 'Определить автоматически',
},
auth: {
loginRequired: 'Требуется авторизация',
loginDescription: 'Для оформления заказа войдите через Telegram',
checking: 'Проверка...',
loginWithTelegram: 'Войти через Telegram',
orScanQr: 'Или отсканируйте QR-код',
loginNote: 'После входа вы будете перенаправлены обратно',
},
};

View File

@@ -146,7 +146,6 @@ export interface Translations {
mediumStock: string;
addToCart: string;
description: string;
specifications: string;
reviews: string;
yourReview: string;
leaveReview: string;
@@ -184,17 +183,4 @@ export interface Translations {
retry: string;
loading: string;
};
location: {
allRegions: string;
chooseRegion: string;
detectAuto: string;
};
auth: {
loginRequired: string;
loginDescription: string;
checking: string;
loginWithTelegram: string;
orScanQr: string;
loginNote: string;
};
}

View File

@@ -1,37 +0,0 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { LocationService } from '../services/location.service';
import { LanguageService } from '../services/language.service';
import { environment } from '../../environments/environment';
/**
* Interceptor that attaches X-Region and X-Language headers
* to every outgoing request aimed at our API.
*
* The backend reads these headers to:
* - filter catalog by region
* - return translated content in the requested language
*/
export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
// Only attach headers to our own API requests
if (!req.url.startsWith(environment.apiUrl)) {
return next(req);
}
const locationService = inject(LocationService);
const languageService = inject(LanguageService);
const regionId = locationService.regionId(); // '' when global
const lang = languageService.currentLanguage(); // 'ru' | 'en' | 'hy'
let headers = req.headers;
if (regionId) {
headers = headers.set('X-Region', regionId);
}
if (lang) {
headers = headers.set('X-Language', lang);
}
return next(req.clone({ headers }));
};

View File

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

View File

@@ -1,765 +0,0 @@
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of, delay } from 'rxjs';
import { environment } from '../../environments/environment';
// ─── Mock Categories (backOffice format: string IDs, img, subcategories, visible) ───
const MOCK_CATEGORIES = [
{
id: 'electronics',
categoryID: 1,
name: 'Электроника',
parentID: 0,
visible: true,
priority: 1,
img: 'https://images.unsplash.com/photo-1498049794561-7780e7231661?w=400&h=300&fit=crop',
icon: 'https://images.unsplash.com/photo-1498049794561-7780e7231661?w=400&h=300&fit=crop',
projectId: 'dexar',
itemCount: 15,
subcategories: [
{
id: 'smartphones',
name: 'Смартфоны',
visible: true,
priority: 1,
img: 'https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&h=300&fit=crop',
categoryId: 'electronics',
parentId: 'electronics',
itemCount: 8,
hasItems: true,
subcategories: []
},
{
id: 'laptops',
name: 'Ноутбуки',
visible: true,
priority: 2,
img: 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?w=400&h=300&fit=crop',
categoryId: 'electronics',
parentId: 'electronics',
itemCount: 6,
hasItems: true,
subcategories: []
}
]
},
{
id: 'clothing',
categoryID: 2,
name: 'Одежда',
parentID: 0,
visible: true,
priority: 2,
img: 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=400&h=300&fit=crop',
icon: 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=400&h=300&fit=crop',
projectId: 'dexar',
itemCount: 25,
subcategories: [
{
id: 'mens',
name: 'Мужская',
visible: true,
priority: 1,
img: 'https://images.unsplash.com/photo-1490578474895-699cd4e2cf59?w=400&h=300&fit=crop',
categoryId: 'clothing',
parentId: 'clothing',
itemCount: 12,
hasItems: true,
subcategories: []
},
{
id: 'womens',
name: 'Женская',
visible: true,
priority: 2,
img: 'https://images.unsplash.com/photo-1487222477894-8943e31ef7b2?w=400&h=300&fit=crop',
categoryId: 'clothing',
parentId: 'clothing',
itemCount: 13,
hasItems: true,
subcategories: []
}
]
},
{
id: 'home',
categoryID: 3,
name: 'Дом и сад',
parentID: 0,
visible: true,
priority: 3,
img: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400&h=300&fit=crop',
icon: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400&h=300&fit=crop',
projectId: 'dexar',
itemCount: 8,
subcategories: []
},
// Subcategories as flat entries (for the legacy flat category list)
{
id: 'smartphones',
categoryID: 11,
name: 'Смартфоны',
parentID: 1,
visible: true,
priority: 1,
img: 'https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&h=300&fit=crop',
icon: 'https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&h=300&fit=crop',
itemCount: 8
},
{
id: 'laptops',
categoryID: 12,
name: 'Ноутбуки',
parentID: 1,
visible: true,
priority: 2,
img: 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?w=400&h=300&fit=crop',
icon: 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?w=400&h=300&fit=crop',
itemCount: 6
},
{
id: 'mens',
categoryID: 21,
name: 'Мужская одежда',
parentID: 2,
visible: true,
priority: 1,
img: 'https://images.unsplash.com/photo-1490578474895-699cd4e2cf59?w=400&h=300&fit=crop',
icon: 'https://images.unsplash.com/photo-1490578474895-699cd4e2cf59?w=400&h=300&fit=crop',
itemCount: 12
},
{
id: 'womens',
categoryID: 22,
name: 'Женская одежда',
parentID: 2,
visible: true,
priority: 2,
img: 'https://images.unsplash.com/photo-1487222477894-8943e31ef7b2?w=400&h=300&fit=crop',
icon: 'https://images.unsplash.com/photo-1487222477894-8943e31ef7b2?w=400&h=300&fit=crop',
itemCount: 13
}
];
// ─── Mock Items (backOffice format with ALL fields) ───
const MOCK_ITEMS: any[] = [
{
id: 'iphone15',
itemID: 101,
name: 'iPhone 15 Pro Max',
visible: true,
priority: 1,
quantity: 50,
price: 149990,
discount: 0,
currency: 'RUB',
rating: 4.8,
remainings: 'high',
categoryID: 11,
imgs: [
'https://images.unsplash.com/photo-1695048133142-1a20484d2569?w=600&h=400&fit=crop',
'https://images.unsplash.com/photo-1592750475338-74b7b21085ab?w=600&h=400&fit=crop'
],
photos: [
{ url: 'https://images.unsplash.com/photo-1695048133142-1a20484d2569?w=600&h=400&fit=crop' },
{ url: 'https://images.unsplash.com/photo-1592750475338-74b7b21085ab?w=600&h=400&fit=crop' }
],
tags: ['new', 'featured', 'apple'],
badges: ['new', 'bestseller'],
simpleDescription: 'Новейший iPhone с титановым корпусом и чипом A17 Pro',
description: [
{ key: 'Цвет', value: 'Натуральный титан' },
{ key: 'Память', value: '256 ГБ' },
{ key: 'Дисплей', value: '6.7" Super Retina XDR' },
{ key: 'Процессор', value: 'A17 Pro' },
{ key: 'Камера', value: '48 Мп основная' },
{ key: 'Аккумулятор', value: '4441 мАч' }
],
descriptionFields: [
{ key: 'Цвет', value: 'Натуральный титан' },
{ key: 'Память', value: '256 ГБ' },
{ key: 'Дисплей', value: '6.7" Super Retina XDR' },
{ key: 'Процессор', value: 'A17 Pro' },
{ key: 'Камера', value: '48 Мп основная' },
{ key: 'Аккумулятор', value: '4441 мАч' }
],
subcategoryId: 'smartphones',
translations: {
en: {
name: 'iPhone 15 Pro Max',
simpleDescription: 'Latest iPhone with titanium body and A17 Pro chip',
description: [
{ key: 'Color', value: 'Natural Titanium' },
{ key: 'Storage', value: '256GB' },
{ key: 'Display', value: '6.7" Super Retina XDR' },
{ key: 'Chip', value: 'A17 Pro' },
{ key: 'Camera', value: '48MP main' },
{ key: 'Battery', value: '4441 mAh' }
]
}
},
comments: [
{ id: 'c1', text: 'Отличный телефон! Камера просто огонь 🔥', author: 'Иван Петров', stars: 5, createdAt: '2025-12-15T10:30:00Z' },
{ id: 'c2', text: 'Батарея держит весь день, очень доволен.', author: 'Мария Козлова', stars: 4, createdAt: '2026-01-05T14:20:00Z' }
],
callbacks: [
{ rating: 5, content: 'Отличный телефон! Камера просто огонь 🔥', userID: 'Иван Петров', timestamp: '2025-12-15T10:30:00Z' },
{ rating: 4, content: 'Батарея держит весь день, очень доволен.', userID: 'Мария Козлова', timestamp: '2026-01-05T14:20:00Z' }
],
questions: []
},
{
id: 'samsung-s24',
itemID: 102,
name: 'Samsung Galaxy S24 Ultra',
visible: true,
priority: 2,
quantity: 35,
price: 129990,
discount: 10,
currency: 'RUB',
rating: 4.6,
remainings: 'high',
categoryID: 11,
imgs: [
'https://images.unsplash.com/photo-1610945415295-d9bbf067e59c?w=600&h=400&fit=crop'
],
photos: [
{ url: 'https://images.unsplash.com/photo-1610945415295-d9bbf067e59c?w=600&h=400&fit=crop' }
],
tags: ['new', 'android', 'samsung'],
badges: ['new', 'sale'],
simpleDescription: 'Премиальный флагман Samsung с S Pen',
description: [
{ key: 'Цвет', value: 'Титановый серый' },
{ key: 'Память', value: '512 ГБ' },
{ key: 'ОЗУ', value: '12 ГБ' },
{ key: 'Дисплей', value: '6.8" Dynamic AMOLED 2X' }
],
descriptionFields: [
{ key: 'Цвет', value: 'Титановый серый' },
{ key: 'Память', value: '512 ГБ' },
{ key: 'ОЗУ', value: '12 ГБ' },
{ key: 'Дисплей', value: '6.8" Dynamic AMOLED 2X' }
],
subcategoryId: 'smartphones',
translations: {
en: {
name: 'Samsung Galaxy S24 Ultra',
simpleDescription: 'Premium Samsung flagship with S Pen',
description: [
{ key: 'Color', value: 'Titanium Gray' },
{ key: 'Storage', value: '512GB' },
{ key: 'RAM', value: '12GB' },
{ key: 'Display', value: '6.8" Dynamic AMOLED 2X' }
]
}
},
comments: [
{ id: 'c3', text: 'S Pen — топ, использую каждый день.', author: 'Алексей', stars: 5, createdAt: '2026-01-20T08:10:00Z' }
],
callbacks: [
{ rating: 5, content: 'S Pen — топ, использую каждый день.', userID: 'Алексей', timestamp: '2026-01-20T08:10:00Z' }
],
questions: []
},
{
id: 'pixel-8',
itemID: 103,
name: 'Google Pixel 8 Pro',
visible: true,
priority: 3,
quantity: 20,
price: 89990,
discount: 15,
currency: 'RUB',
rating: 4.5,
remainings: 'medium',
categoryID: 11,
imgs: [
'https://images.unsplash.com/photo-1598327105666-5b89351aff97?w=600&h=400&fit=crop'
],
photos: [
{ url: 'https://images.unsplash.com/photo-1598327105666-5b89351aff97?w=600&h=400&fit=crop' }
],
tags: ['sale', 'android', 'ai', 'google'],
badges: ['sale', 'hot'],
simpleDescription: 'Лучший смартфон для ИИ-фотографии',
description: [
{ key: 'Цвет', value: 'Bay Blue' },
{ key: 'Память', value: '256 ГБ' },
{ key: 'Процессор', value: 'Tensor G3' }
],
descriptionFields: [
{ key: 'Цвет', value: 'Bay Blue' },
{ key: 'Память', value: '256 ГБ' },
{ key: 'Процессор', value: 'Tensor G3' }
],
subcategoryId: 'smartphones',
translations: {},
comments: [],
callbacks: [],
questions: [
{ question: 'Поддерживает eSIM?', answer: 'Да, поддерживает dual eSIM.', upvotes: 12, downvotes: 0 }
]
},
{
id: 'macbook-pro',
itemID: 104,
name: 'MacBook Pro 16" M3 Max',
visible: true,
priority: 1,
quantity: 15,
price: 299990,
discount: 0,
currency: 'RUB',
rating: 4.9,
remainings: 'low',
categoryID: 12,
imgs: [
'https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=600&h=400&fit=crop',
'https://images.unsplash.com/photo-1541807084-5c52b6b3adef?w=600&h=400&fit=crop'
],
photos: [
{ url: 'https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=600&h=400&fit=crop' },
{ url: 'https://images.unsplash.com/photo-1541807084-5c52b6b3adef?w=600&h=400&fit=crop' }
],
tags: ['featured', 'professional', 'apple'],
badges: ['exclusive', 'limited'],
simpleDescription: 'Мощный ноутбук для профессионалов',
description: [
{ key: 'Процессор', value: 'Apple M3 Max' },
{ key: 'ОЗУ', value: '36 ГБ' },
{ key: 'Память', value: '1 ТБ SSD' },
{ key: 'Дисплей', value: '16.2" Liquid Retina XDR' },
{ key: 'Батарея', value: 'До 22 ч' }
],
descriptionFields: [
{ key: 'Процессор', value: 'Apple M3 Max' },
{ key: 'ОЗУ', value: '36 ГБ' },
{ key: 'Память', value: '1 ТБ SSD' },
{ key: 'Дисплей', value: '16.2" Liquid Retina XDR' },
{ key: 'Батарея', value: 'До 22 ч' }
],
subcategoryId: 'laptops',
translations: {
en: {
name: 'MacBook Pro 16" M3 Max',
simpleDescription: 'Powerful laptop for professionals',
description: [
{ key: 'Chip', value: 'Apple M3 Max' },
{ key: 'RAM', value: '36GB' },
{ key: 'Storage', value: '1TB SSD' },
{ key: 'Display', value: '16.2" Liquid Retina XDR' },
{ key: 'Battery', value: 'Up to 22h' }
]
}
},
comments: [
{ id: 'c4', text: 'Невероятная производительность. Рендер в 3 раза быстрее.', author: 'Дизайнер Про', stars: 5, createdAt: '2025-11-15T12:00:00Z' },
{ id: 'c5', text: 'Стоит каждого рубля. Экран — сказка.', author: 'Видеоредактор', stars: 5, createdAt: '2026-02-01T09:00:00Z' }
],
callbacks: [
{ rating: 5, content: 'Невероятная производительность. Рендер в 3 раза быстрее.', userID: 'Дизайнер Про', timestamp: '2025-11-15T12:00:00Z' },
{ rating: 5, content: 'Стоит каждого рубля. Экран — сказка.', userID: 'Видеоредактор', timestamp: '2026-02-01T09:00:00Z' }
],
questions: []
},
{
id: 'dell-xps',
itemID: 105,
name: 'Dell XPS 15',
visible: true,
priority: 2,
quantity: 3,
price: 179990,
discount: 5,
currency: 'RUB',
rating: 4.3,
remainings: 'low',
categoryID: 12,
imgs: [
'https://images.unsplash.com/photo-1593642702749-b7d2a804c22e?w=600&h=400&fit=crop'
],
photos: [
{ url: 'https://images.unsplash.com/photo-1593642702749-b7d2a804c22e?w=600&h=400&fit=crop' }
],
tags: ['windows', 'professional'],
badges: ['limited'],
simpleDescription: 'Тонкий и мощный Windows ноутбук',
description: [
{ key: 'Процессор', value: 'Intel Core i9-13900H' },
{ key: 'ОЗУ', value: '32 ГБ' },
{ key: 'Дисплей', value: '15.6" OLED 3.5K' }
],
descriptionFields: [
{ key: 'Процессор', value: 'Intel Core i9-13900H' },
{ key: 'ОЗУ', value: '32 ГБ' },
{ key: 'Дисплей', value: '15.6" OLED 3.5K' }
],
subcategoryId: 'laptops',
translations: {},
comments: [],
callbacks: [],
questions: []
},
{
id: 'jacket-leather',
itemID: 201,
name: 'Кожаная куртка Premium',
visible: true,
priority: 1,
quantity: 8,
price: 34990,
discount: 20,
currency: 'RUB',
rating: 4.7,
remainings: 'medium',
categoryID: 21,
imgs: [
'https://images.unsplash.com/photo-1551028719-00167b16eac5?w=600&h=400&fit=crop'
],
photos: [
{ url: 'https://images.unsplash.com/photo-1551028719-00167b16eac5?w=600&h=400&fit=crop' }
],
tags: ['leather', 'premium', 'winter'],
badges: ['sale', 'bestseller'],
simpleDescription: 'Стильная мужская кожаная куртка из натуральной кожи',
description: [
{ key: 'Материал', value: 'Натуральная кожа' },
{ key: 'Размеры', value: 'S, M, L, XL, XXL' },
{ key: 'Цвет', value: 'Чёрный' },
{ key: 'Подкладка', value: 'Полиэстер 100%' }
],
descriptionFields: [
{ key: 'Материал', value: 'Натуральная кожа' },
{ key: 'Размеры', value: 'S, M, L, XL, XXL' },
{ key: 'Цвет', value: 'Чёрный' },
{ key: 'Подкладка', value: 'Полиэстер 100%' }
],
subcategoryId: 'mens',
translations: {
en: {
name: 'Premium Leather Jacket',
simpleDescription: 'Stylish men\'s genuine leather jacket',
description: [
{ key: 'Material', value: 'Genuine Leather' },
{ key: 'Sizes', value: 'S, M, L, XL, XXL' },
{ key: 'Color', value: 'Black' },
{ key: 'Lining', value: '100% Polyester' }
]
}
},
comments: [
{ id: 'c6', text: 'Качество кожи отличное, сидит идеально.', author: 'Антон', stars: 5, createdAt: '2026-01-10T16:30:00Z' }
],
callbacks: [
{ rating: 5, content: 'Качество кожи отличное, сидит идеально.', userID: 'Антон', timestamp: '2026-01-10T16:30:00Z' }
],
questions: []
},
{
id: 'dress-silk',
itemID: 202,
name: 'Шёлковое платье Elegance',
visible: true,
priority: 1,
quantity: 12,
price: 18990,
discount: 0,
currency: 'RUB',
rating: 4.9,
remainings: 'high',
categoryID: 22,
imgs: [
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=600&h=400&fit=crop'
],
photos: [
{ url: 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=600&h=400&fit=crop' }
],
tags: ['silk', 'elegant', 'new'],
badges: ['new', 'featured'],
simpleDescription: 'Элегантное шёлковое платье для особых случаев',
description: [
{ key: 'Материал', value: '100% Шёлк' },
{ key: 'Размеры', value: 'XS, S, M, L' },
{ key: 'Цвет', value: 'Бордовый' },
{ key: 'Длина', value: 'Миди' }
],
descriptionFields: [
{ key: 'Материал', value: '100% Шёлк' },
{ key: 'Размеры', value: 'XS, S, M, L' },
{ key: 'Цвет', value: 'Бордовый' },
{ key: 'Длина', value: 'Миди' }
],
subcategoryId: 'womens',
translations: {},
comments: [
{ id: 'c7', text: 'Восхитительное платье! Ткань потрясающая.', author: 'Елена', stars: 5, createdAt: '2026-02-14T20:00:00Z' },
{ id: 'c8', text: 'Идеально на вечер. Рекомендую!', author: 'Наталья', stars: 5, createdAt: '2026-02-10T11:00:00Z' }
],
callbacks: [
{ rating: 5, content: 'Восхитительное платье! Ткань потрясающая.', userID: 'Елена', timestamp: '2026-02-14T20:00:00Z' },
{ rating: 5, content: 'Идеально на вечер. Рекомендую!', userID: 'Наталья', timestamp: '2026-02-10T11:00:00Z' }
],
questions: []
},
{
id: 'hoodie-basic',
itemID: 203,
name: 'Худи Oversize Basic',
visible: true,
priority: 3,
quantity: 45,
price: 5990,
discount: 0,
currency: 'RUB',
rating: 4.2,
remainings: 'high',
categoryID: 21,
imgs: [
'https://images.unsplash.com/photo-1556821840-3a63f95609a7?w=600&h=400&fit=crop'
],
photos: [
{ url: 'https://images.unsplash.com/photo-1556821840-3a63f95609a7?w=600&h=400&fit=crop' }
],
tags: ['casual', 'basic'],
badges: [],
simpleDescription: 'Удобное худи свободного кроя на каждый день',
description: [
{ key: 'Материал', value: 'Хлопок 80%, Полиэстер 20%' },
{ key: 'Размеры', value: 'S, M, L, XL' },
{ key: 'Цвет', value: 'Серый меланж' }
],
descriptionFields: [
{ key: 'Материал', value: 'Хлопок 80%, Полиэстер 20%' },
{ key: 'Размеры', value: 'S, M, L, XL' },
{ key: 'Цвет', value: 'Серый меланж' }
],
subcategoryId: 'mens',
translations: {},
comments: [],
callbacks: [],
questions: []
},
{
id: 'sneakers-run',
itemID: 204,
name: 'Кроссовки AirPulse Run',
visible: true,
priority: 2,
quantity: 0,
price: 12990,
discount: 30,
currency: 'RUB',
rating: 4.4,
remainings: 'out',
categoryID: 21,
imgs: [
'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=600&h=400&fit=crop'
],
photos: [
{ url: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=600&h=400&fit=crop' }
],
tags: ['sport', 'running'],
badges: ['sale', 'hot'],
simpleDescription: 'Лёгкие беговые кроссовки с пенной амортизацией',
description: [
{ key: 'Верх', value: 'Текстильная сетка' },
{ key: 'Подошва', value: 'Пена EVA' },
{ key: 'Вес', value: '260 г' }
],
descriptionFields: [
{ key: 'Верх', value: 'Текстильная сетка' },
{ key: 'Подошва', value: 'Пена EVA' },
{ key: 'Вес', value: '260 г' }
],
subcategoryId: 'mens',
translations: {},
comments: [
{ id: 'c9', text: 'Нет в наличии уже месяц... Верните!', author: егун42', stars: 3, createdAt: '2026-02-05T07:00:00Z' }
],
callbacks: [
{ rating: 3, content: 'Нет в наличии уже месяц... Верните!', userID: егун42', timestamp: '2026-02-05T07:00:00Z' }
],
questions: []
},
{
id: 'lamp-smart',
itemID: 301,
name: 'Умная лампа Homelight Pro',
visible: true,
priority: 1,
quantity: 100,
price: 3990,
discount: 0,
currency: 'RUB',
rating: 4.1,
remainings: 'high',
categoryID: 3,
imgs: [
'https://images.unsplash.com/photo-1507473885765-e6ed057ab6fe?w=600&h=400&fit=crop'
],
photos: [
{ url: 'https://images.unsplash.com/photo-1507473885765-e6ed057ab6fe?w=600&h=400&fit=crop' }
],
tags: ['smart-home', 'lighting'],
badges: ['featured'],
simpleDescription: 'Wi-Fi лампа с управлением через приложение и голосом',
description: [
{ key: 'Яркость', value: '1100 лм' },
{ key: 'Цветовая t°', value: '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 /cart (add to cart / create payment)
if (url.endsWith('/cart') && req.method === 'POST') {
const body = req.body as any;
if (body?.amount) {
// Payment mock
return respond({
qrId: 'mock-qr-' + Date.now(),
qrStatus: 'CREATED',
qrExpirationDate: new Date(Date.now() + 180000).toISOString(),
payload: 'https://example.com/pay/mock',
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment'
}, 300);
}
return respond({ message: 'Added (mock)' });
}
// ── PATCH /cart
if (url.endsWith('/cart') && req.method === 'PATCH') {
return respond({ message: 'Updated (mock)' });
}
// ── DELETE /cart
if (url.endsWith('/cart') && req.method === 'DELETE') {
return respond({ message: 'Removed (mock)' });
}
// ── POST /comment
if (url.endsWith('/comment') && req.method === 'POST') {
return respond({ message: 'Review submitted (mock)' }, 200);
}
// ── POST /purchase-email
if (url.endsWith('/purchase-email') && req.method === 'POST') {
return respond({ message: 'Email sent (mock)' }, 200);
}
// ── GET /qr/payment/:id (always return success for testing)
if (url.includes('/qr/payment/') && req.method === 'GET') {
return respond({
paymentStatus: 'SUCCESS',
code: 'SUCCESS',
amount: 0,
currency: 'RUB',
qrId: 'mock',
transactionId: 999,
transactionDate: new Date().toISOString(),
additionalInfo: '',
paymentPurpose: '',
createDate: new Date().toISOString(),
order: 'mock-order',
qrExpirationDate: new Date().toISOString(),
phoneNumber: ''
}, 500);
}
// Fallback — pass through
return next(req);
};

View File

@@ -1,20 +0,0 @@
export interface AuthSession {
sessionId: string;
telegramUserId: number;
username: string | null;
displayName: string;
active: boolean;
expiresAt: string;
}
export interface TelegramAuthData {
id: number;
first_name: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
}
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';

View File

@@ -6,28 +6,4 @@ export interface Category {
wideBanner?: string;
itemCount?: number;
priority?: number;
// BackOffice API fields
id?: string;
visible?: boolean;
img?: string;
projectId?: string;
subcategories?: Subcategory[];
}
export interface Subcategory {
id: string;
name: string;
visible?: boolean;
priority?: number;
img?: string;
categoryId: string;
parentId: string;
itemCount?: number;
hasItems?: boolean;
subcategories?: Subcategory[];
}
export interface CategoryTranslation {
name?: string;
}

View File

@@ -1,4 +1,2 @@
export * from './category.model';
export * from './item.model';
export * from './location.model';
export * from './auth.model';

View File

@@ -5,25 +5,6 @@ export interface Photo {
type?: string;
}
export interface DescriptionField {
key: string;
value: string;
}
export interface Comment {
id?: string;
text: string;
author?: string;
stars?: number;
createdAt?: string;
}
export interface ItemTranslation {
name?: string;
simpleDescription?: string;
description?: DescriptionField[];
}
export interface Review {
rating?: number;
content?: string;
@@ -56,20 +37,6 @@ export interface Item {
callbacks: Review[] | null;
questions: Question[] | null;
partnerID?: string;
quantity?: number;
// BackOffice API fields
id?: string;
visible?: boolean;
priority?: number;
imgs?: string[];
tags?: string[];
badges?: string[];
simpleDescription?: string;
descriptionFields?: DescriptionField[];
subcategoryId?: string;
translations?: Record<string, ItemTranslation>;
comments?: Comment[];
}
export interface CartItem extends Item {

View File

@@ -1,17 +0,0 @@
export interface Region {
id: string;
city: string;
country: string;
countryCode: string;
timezone?: string;
}
export interface GeoIpResponse {
city: string;
country: string;
countryCode: string;
region?: string;
timezone?: string;
lat?: number;
lon?: number;
}

View File

@@ -44,15 +44,7 @@
</button>
</div>
<p class="item-description">{{ item.simpleDescription || item?.description?.substring?.(0, 100) || '' }}...</p>
@if (item.badges && item.badges.length > 0) {
<div class="cart-item-badges">
@for (badge of item.badges; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
<p class="item-description">{{ item.description.substring(0, 100) }}...</p>
<div class="item-footer">
<div class="item-pricing">

View File

@@ -2,16 +2,17 @@ import { Component, computed, ChangeDetectionStrategy, signal, OnDestroy, inject
import { DecimalPipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { CartService, ApiService, LanguageService, AuthService } from '../../services';
import { CartService, ApiService, LanguageService } from '../../services';
import { Item, CartItem } from '../../models';
import { interval, Subscription } from 'rxjs';
import { switchMap, take } from 'rxjs/operators';
import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component';
import { environment } from '../../../environments/environment';
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass } from '../../utils/item.utils';
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
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({
selector: 'app-cart',
@@ -28,7 +29,6 @@ export class CartComponent implements OnDestroy {
isnovo = environment.theme === 'novo';
private i18n = inject(TranslateService);
private authService = inject(AuthService);
// Swipe state
swipedItemId = signal<number | null>(null);
@@ -51,7 +51,7 @@ export class CartComponent implements OnDestroy {
emailSubmitting = signal<boolean>(false);
paidItems: CartItem[] = [];
maxChecks = 36; // 36 checks * 5 seconds = 180 seconds (3 minutes)
maxChecks = PAYMENT_MAX_CHECKS;
private pollingSubscription?: Subscription;
private closeTimeout?: ReturnType<typeof setTimeout>;
@@ -129,18 +129,12 @@ export class CartComponent implements OnDestroy {
readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId;
readonly getDiscountedPrice = getDiscountedPrice;
readonly getBadgeClass = getBadgeClass;
checkout(): void {
if (!this.termsAccepted) {
alert(this.i18n.t('cart.acceptTerms'));
return;
}
// Auth gate: require Telegram login before payment
if (!this.authService.isAuthenticated()) {
this.authService.requestLogin();
return;
}
this.openPaymentPopup();
}
@@ -174,7 +168,7 @@ export class CartComponent implements OnDestroy {
const paymentData = {
amount: this.totalPrice(),
currency: 'RUB',
currency: this.items()[0]?.currency || 'RUB',
siteuserID: userId,
siteorderID: orderId,
redirectUrl: '',
@@ -200,15 +194,17 @@ export class CartComponent implements OnDestroy {
error: (err) => {
console.error('Error creating payment:', err);
this.paymentStatus.set('timeout');
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => {
this.closePaymentPopup();
}, 4000);
}, PAYMENT_ERROR_CLOSE_MS);
}
});
}
startPolling(): void {
this.pollingSubscription = interval(5000) // every 5 seconds
this.stopPolling();
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
.pipe(
take(this.maxChecks), // maximum 36 checks (3 minutes)
switchMap(() => {
@@ -232,17 +228,19 @@ export class CartComponent implements OnDestroy {
if (this.paymentStatus() === 'waiting') {
this.paymentStatus.set('timeout');
// Close popup after showing timeout message
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => {
this.closePaymentPopup();
}, 3000);
}, PAYMENT_TIMEOUT_CLOSE_MS);
}
},
error: (err) => {
console.error('Error checking payment status:', err);
// Continue checking even on error until time runs out
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => {
this.closePaymentPopup();
}, 3000);
}, PAYMENT_TIMEOUT_CLOSE_MS);
}
});
}
@@ -258,7 +256,7 @@ export class CartComponent implements OnDestroy {
if (url) {
navigator.clipboard.writeText(url).then(() => {
this.linkCopied.set(true);
setTimeout(() => this.linkCopied.set(false), 2000);
setTimeout(() => this.linkCopied.set(false), LINK_COPIED_DURATION_MS);
}).catch(err => {
console.error(this.i18n.t('cart.copyError'), err);
});

View File

@@ -9,20 +9,13 @@
@if (!error()) {
<div class="items-grid">
@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">
<div class="item-image">
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" width="300" height="300" />
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" />
@if (item.discount > 0) {
<div class="discount-badge">-{{ item.discount }}%</div>
}
@if (item.badges && item.badges.length > 0) {
<div class="item-badges-overlay">
@for (badge of item.badges; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
</div>
<div class="item-details">
@@ -52,19 +45,29 @@
</div>
</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 }}
</button>
</div>
}
</div>
@if (loading() && items().length > 0) {
<div class="loading-more">
<div class="spinner"></div>
<p>{{ 'category.loadingMore' | translate }}</p>
</div>
}
@if (loading() && items().length > 0) {
@for (i of skeletonSlots; track i) {
<div class="item-card skeleton-card">
<div class="item-link">
<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) {
<div class="no-more">

View File

@@ -95,7 +95,7 @@
.items-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 30px;
margin-bottom: 40px;
width: 100%;
@@ -103,8 +103,10 @@
.item-card {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
@@ -139,7 +141,7 @@
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
background: #f0f0f0;
img {
width: 100%;
@@ -147,7 +149,7 @@
object-fit: contain;
background: white;
padding: 12px;
transition: transform 0.3s ease;
transition: transform 0.3s ease, opacity 0.3s ease;
}
&:hover img {
@@ -192,6 +194,7 @@
margin: 0;
line-height: 1.3;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
@@ -287,11 +290,6 @@
}
}
.loading-more {
text-align: center;
padding: 40px 20px;
}
.spinner {
width: 40px;
height: 40px;
@@ -312,24 +310,77 @@
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
@media (max-width: 1200px) {
.items-grid {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
}
}
@media (max-width: 992px) {
.items-grid {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
}
@media (max-width: 768px) {
.items-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
@@ -353,7 +404,7 @@
}
.items-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}

View File

@@ -2,11 +2,13 @@ import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStra
import { DecimalPipe } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService } from '../../services';
import { PrefetchService } from '../../services/prefetch.service';
import { Item } from '../../models';
import { Subscription } from 'rxjs';
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass } from '../../utils/item.utils';
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS, ITEMS_PER_PAGE } from '../../config/constants';
@Component({
selector: 'app-category',
@@ -23,7 +25,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
hasMore = signal(true);
private skip = 0;
private readonly count = 20;
private readonly count = ITEMS_PER_PAGE;
private isLoadingMore = false;
private routeSubscription?: Subscription;
private scrollTimeout?: ReturnType<typeof setTimeout>;
@@ -31,7 +33,8 @@ export class CategoryComponent implements OnInit, OnDestroy {
constructor(
private route: ActivatedRoute,
private apiService: ApiService,
private cartService: CartService
private cartService: CartService,
private prefetchService: PrefetchService
) {}
ngOnInit(): void {
@@ -90,12 +93,12 @@ export class CategoryComponent implements OnInit, OnDestroy {
this.scrollTimeout = setTimeout(() => {
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) {
this.loadItems();
}
}, 100);
}, SCROLL_DEBOUNCE_MS);
}
addToCart(itemID: number, event: Event): void {
@@ -104,8 +107,12 @@ export class CategoryComponent implements OnInit, OnDestroy {
this.cartService.addItem(itemID);
}
onItemHover(itemID: number): void {
this.prefetchService.prefetchItem(itemID);
}
readonly skeletonSlots = Array.from({ length: 8 });
readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId;
readonly getBadgeClass = getBadgeClass;
}

View File

@@ -19,10 +19,23 @@
<app-items-carousel />
@if (loading()) {
<div class="novo-loading">
<div class="novo-spinner"></div>
<p>{{ 'home.loading' | translate }}</p>
</div>
<section class="novo-categories">
<div class="novo-section-header">
<div class="skeleton-line" style="height: 32px; width: 200px; margin: 0 auto 12px;"></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()) {
@@ -101,10 +114,20 @@
<app-items-carousel />
@if (loading()) {
<div class="dexar-loading">
<div class="dexar-spinner"></div>
<p>{{ 'home.loadingDexar' | translate }}</p>
</div>
<section class="dexar-categories">
<div class="skeleton-line" style="height: 36px; width: 220px; margin-bottom: 40px;"></div>
<div class="dexar-categories-grid">
@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()) {

View File

@@ -896,3 +896,26 @@
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,4 +1,4 @@
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 { ApiService, LanguageService } from '../../services';
import { Category } from '../../models';
@@ -14,13 +14,14 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
styleUrls: ['./home.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HomeComponent implements OnInit {
export class HomeComponent implements OnInit, OnDestroy {
brandName = environment.brandFullName;
isnovo = environment.theme === 'novo';
categories = signal<Category[]>([]);
wideCategories = signal<Set<number>>(new Set());
loading = signal(true);
error = signal<string | null>(null);
readonly skeletonSlots = Array.from({ length: 6 });
// Memoized computed values for performance
topLevelCategories = computed(() => {
@@ -56,6 +57,14 @@ export class HomeComponent implements OnInit {
this.loadCategories();
}
ngOnDestroy(): void {
this.pendingImages.forEach(img => {
img.onload = null;
img.onerror = null;
});
this.pendingImages.clear();
}
loadCategories(): void {
this.loading.set(true);
this.apiService.getCategories().subscribe({
@@ -84,13 +93,17 @@ export class HomeComponent implements OnInit {
return this.wideCategories().has(categoryID);
}
private pendingImages = new Set<HTMLImageElement>();
private detectWideImages(categories: Category[]): void {
const topLevel = categories.filter(c => c.parentID === 0);
topLevel.forEach(cat => {
if (!cat.wideBanner) return;
const img = new Image();
this.pendingImages.add(img);
img.onload = () => {
this.pendingImages.delete(img);
const ratio = img.naturalWidth / img.naturalHeight;
if (ratio > 2) {
this.wideCategories.update(set => {
@@ -100,6 +113,7 @@ export class HomeComponent implements OnInit {
});
}
};
img.onerror = () => this.pendingImages.delete(img);
img.src = cat.wideBanner;
});
}

View File

@@ -1,4 +1,4 @@
<div class="legal-page">
<div class="legal-page">
<div class="legal-container">
<h1>About the company LLC «INT FIN LOGISTIC»</h1>
@@ -65,7 +65,7 @@
<p><strong>Director:</strong> Оганнисян Ашот Рафикович</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 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>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>

View File

@@ -1,4 +1,4 @@
<div class="legal-page">
<div class="legal-page">
<div class="legal-container">
<h1>«ИНТ ФИН ЛОГИСТИК» ՍՊԸ ընկերության մասին</h1>
@@ -65,7 +65,6 @@
<p><strong>Տնօրեն՝</strong> Оганнисян Ашот Рафикович</p>
<p><strong>Իրավաբանական հասցե՝</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</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>Բանկ՝ АО "Райффайзенбанк"<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>

View File

@@ -1,4 +1,4 @@
<div class="legal-page">
<div class="legal-page">
<div class="legal-container">
<h1>О компании ООО «ИНТ ФИН ЛОГИСТИК»</h1>
@@ -65,7 +65,7 @@
<p><strong>Директор:</strong> Оганнисян Ашот Рафикович</p>
<p><strong>Юридический адрес:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</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>Банк: АО "Райффайзенбанк"<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>

View File

@@ -1,4 +1,4 @@
<div class="legal-page">
<div class="legal-page">
<div class="legal-container">
<h1>Contacts</h1>
@@ -35,7 +35,7 @@
<section class="legal-section">
<h2>Office Addresses</h2>
<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 class="legal-section">

View File

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

View File

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

View File

@@ -15,21 +15,22 @@
</div>
}
@if (item() && !loading()) {
@if (item(); as item) {
@if (!loading()) {
<div class="novo-item-content">
<div class="novo-gallery">
@if (item()?.photos && item()!.photos!.length > 0) {
@if (item.photos && item.photos.length > 0) {
<div class="novo-main-photo">
@if (item()!.photos![selectedPhotoIndex()]?.video) {
<video [src]="item()!.photos![selectedPhotoIndex()].url" controls></video>
@if (item.photos[selectedPhotoIndex()]?.video) {
<video [src]="item.photos[selectedPhotoIndex()].url" controls></video>
} @else {
<img [src]="item()!.photos![selectedPhotoIndex()].url" [alt]="item()!.name" />
<img [src]="item.photos[selectedPhotoIndex()].url" [alt]="item.name" />
}
</div>
@if (item()!.photos!.length > 1) {
@if (item.photos.length > 1) {
<div class="novo-thumbnails">
@for (photo of item()!.photos!; track $index) {
@for (photo of item.photos; track $index) {
<div
class="novo-thumb"
[class.active]="selectedPhotoIndex() === $index"
@@ -55,51 +56,32 @@
</div>
<div class="novo-info">
<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>
}
<h1 class="novo-title">{{ item.name }}</h1>
<div class="novo-rating">
<span class="stars">{{ getRatingStars(item()!.rating) }}</span>
<span class="value">{{ item()!.rating }}</span>
<span class="reviews">({{ item()!.callbacks?.length || 0 }})</span>
<span class="stars">{{ getRatingStars(item.rating) }}</span>
<span class="value">{{ item.rating }}</span>
<span class="reviews">({{ item.callbacks?.length || 0 }})</span>
</div>
<div class="novo-price-block">
@if (item()!.discount > 0) {
@if (item.discount > 0) {
<div class="price-row">
<span class="old-price">{{ item()!.price }} {{ item()!.currency }}</span>
<span class="discount-badge">-{{ item()!.discount }}%</span>
<span class="old-price">{{ item.price }} {{ item.currency }}</span>
<span class="discount-badge">-{{ item.discount }}%</span>
</div>
<div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ item()!.currency }}</div>
<div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ item.currency }}</div>
} @else {
<div class="current-price">{{ item()!.price }} {{ item()!.currency }}</div>
<div class="current-price">{{ item.price }} {{ item.currency }}</div>
}
</div>
<div class="novo-stock">
<span class="stock-label">{{ 'itemDetail.stock' | translate }}</span>
<div class="stock-indicator" [class]="getStockClass()">
<div class="stock-indicator" [class.high]="item.remainings === 'high'" [class.medium]="item.remainings === 'medium'" [class.low]="item.remainings === 'low'">
<span class="dot"></span>
{{ getStockLabel() }}
{{ item.remainings === 'high' ? ('itemDetail.inStock' | translate) : item.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lowStock' | translate) }}
</div>
@if (item()!.quantity != null) {
<span class="stock-qty">({{ item()!.quantity }} шт.)</span>
}
</div>
<button class="novo-add-cart" (click)="addToCart()">
@@ -112,32 +94,14 @@
</button>
<div class="novo-description">
@if (getSimpleDescription()) {
<p class="novo-simple-desc">{{ getSimpleDescription() }}</p>
}
@if (hasDescriptionFields()) {
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
<table class="novo-specs-table">
<tbody>
@for (field of getTranslatedDescriptionFields(); track field.key) {
<tr>
<td class="spec-key">{{ field.key }}</td>
<td class="spec-value">{{ field.value }}</td>
</tr>
}
</tbody>
</table>
} @else {
<h3>{{ 'itemDetail.description' | translate }}</h3>
<div [innerHTML]="getSafeHtml(item()!.description)"></div>
}
<h3>{{ 'itemDetail.description' | translate }}</h3>
<div [innerHTML]="getSafeHtml(item.description)"></div>
</div>
</div>
</div>
<div class="novo-reviews">
<h2>{{ 'itemDetail.reviews' | translate }} ({{ item()!.callbacks?.length || 0 }})</h2>
<h2>{{ 'itemDetail.reviews' | translate }} ({{ item.callbacks?.length || 0 }})</h2>
<!-- novo Review Form -->
<div class="novo-review-form">
@@ -206,8 +170,8 @@
</div>
<div class="novo-reviews-list">
@if (item()!.callbacks && item()!.callbacks!.length > 0) {
@for (review of item()!.callbacks!; track review.userID) {
@if (item.callbacks && item.callbacks.length > 0) {
@for (review of item.callbacks; track $index) {
<div class="novo-review-card">
<div class="review-header">
<div class="reviewer-info">
@@ -226,6 +190,7 @@
}
</div>
</div>
}
}
</div>
} @else {
@@ -245,13 +210,14 @@
</div>
}
@if (item() && !loading()) {
@if (item(); as item) {
@if (!loading()) {
<div class="dx-item-content">
<!-- Gallery: thumbnails left + main photo -->
<div class="dx-gallery">
@if (item()?.photos && item()!.photos!.length > 0) {
@if (item.photos && item.photos.length > 0) {
<div class="dx-thumbnails">
@for (photo of item()!.photos!; track $index) {
@for (photo of item.photos; track $index) {
<div
class="dx-thumb"
[class.active]="selectedPhotoIndex() === $index"
@@ -265,11 +231,11 @@
</div>
}
<div class="dx-main-photo">
@if (item()?.photos && item()!.photos!.length > 0) {
@if (item()!.photos![selectedPhotoIndex()]?.video) {
<video [src]="item()!.photos![selectedPhotoIndex()].url" controls></video>
@if (item.photos && item.photos.length > 0) {
@if (item.photos[selectedPhotoIndex()]?.video) {
<video [src]="item.photos[selectedPhotoIndex()].url" controls></video>
} @else {
<img [src]="item()!.photos![selectedPhotoIndex()].url" [alt]="item()!.name" fetchpriority="high" decoding="async" />
<img [src]="item.photos[selectedPhotoIndex()].url" [alt]="item.name" fetchpriority="high" decoding="async" />
}
} @else {
<div class="dx-no-image">
@@ -286,57 +252,41 @@
<!-- Item Info -->
<div class="dx-info">
<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>
}
<h1 class="dx-title">{{ item.name }}</h1>
<div class="dx-rating">
<div class="dx-stars">
@for (star of [1, 2, 3, 4, 5]; track star) {
<svg width="18" height="18" viewBox="0 0 24 24" [attr.fill]="star <= item()!.rating ? '#497671' : 'none'" [attr.stroke]="star <= item()!.rating ? '#497671' : '#a1b4b5'" stroke-width="2">
<svg width="18" height="18" viewBox="0 0 24 24" [attr.fill]="star <= item.rating ? '#497671' : 'none'" [attr.stroke]="star <= item.rating ? '#497671' : '#a1b4b5'" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
}
</div>
<span class="dx-rating-value">{{ item()!.rating }}</span>
<span class="dx-rating-count">({{ item()!.callbacks?.length || 0 }} {{ 'itemDetail.reviewsCount' | translate }})</span>
<span class="dx-rating-value">{{ item.rating }}</span>
<span class="dx-rating-count">({{ item.callbacks?.length || 0 }} {{ 'itemDetail.reviewsCount' | translate }})</span>
</div>
<div class="dx-price-block">
@if (item()!.discount > 0) {
@if (item.discount > 0) {
<div class="dx-price-row">
<span class="dx-old-price">{{ item()!.price }} {{ item()!.currency }}</span>
<span class="dx-discount-tag">-{{ item()!.discount }}%</span>
<span class="dx-old-price">{{ item.price }} {{ item.currency }}</span>
<span class="dx-discount-tag">-{{ item.discount }}%</span>
</div>
}
<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') : item.price }} {{ item.currency }}
</div>
</div>
<div class="dx-stock">
<span class="dx-stock-label">{{ 'itemDetail.stock' | translate }}</span>
<span class="dx-stock-status" [class]="getStockClass()">
<span class="dx-stock-status"
[class.high]="item.remainings === 'high'"
[class.medium]="item.remainings === 'medium'"
[class.low]="item.remainings === 'low'">
<span class="dx-stock-dot"></span>
{{ getStockLabel() }}
{{ item.remainings === 'high' ? ('itemDetail.inStock' | translate) : item.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lastItems' | translate) }}
</span>
@if (item()!.quantity != null) {
<span class="dx-stock-qty">({{ item()!.quantity }} шт.)</span>
}
</div>
<button class="dx-add-cart" (click)="addToCart()">
@@ -349,33 +299,15 @@
</button>
<div class="dx-description">
@if (getSimpleDescription()) {
<p class="dx-simple-desc">{{ getSimpleDescription() }}</p>
}
@if (hasDescriptionFields()) {
<h2>{{ 'itemDetail.specifications' | translate }}</h2>
<table class="dx-specs-table">
<tbody>
@for (field of getTranslatedDescriptionFields(); track field.key) {
<tr>
<td class="spec-key">{{ field.key }}</td>
<td class="spec-value">{{ field.value }}</td>
</tr>
}
</tbody>
</table>
} @else {
<h2>{{ 'itemDetail.description' | translate }}</h2>
<div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div>
}
<h2>{{ 'itemDetail.description' | translate }}</h2>
<div class="dx-description-text" [innerHTML]="getSafeHtml(item.description)"></div>
</div>
</div>
</div>
<!-- Reviews Section -->
<div class="dx-reviews-section">
<h2>{{ 'itemDetail.reviews' | translate }} ({{ item()!.callbacks?.length || 0 }})</h2>
<h2>{{ 'itemDetail.reviews' | translate }} ({{ item.callbacks?.length || 0 }})</h2>
<div class="dx-review-form">
<h3>{{ 'itemDetail.leaveReview' | translate }}</h3>
@@ -436,8 +368,8 @@
</div>
<div class="dx-reviews-list">
@if (item()?.callbacks && item()!.callbacks!.length > 0) {
@for (callback of item()!.callbacks; track $index) {
@if (item.callbacks && item.callbacks.length > 0) {
@for (callback of item.callbacks; track $index) {
<div class="dx-review-card">
<div class="dx-review-header">
<div class="dx-reviewer">
@@ -468,11 +400,11 @@
</div>
<!-- Q&A Section -->
@if (item()!.questions && item()!.questions!.length > 0) {
@if (item.questions && item.questions.length > 0) {
<div class="dx-qa-section">
<h2>{{ 'itemDetail.qna' | translate }} ({{ item()!.questions!.length }})</h2>
<h2>{{ 'itemDetail.qna' | translate }} ({{ item.questions.length }})</h2>
<div class="dx-qa-list">
@for (question of item()!.questions!; track $index) {
@for (question of item.questions; track $index) {
<div class="dx-qa-card">
<div class="dx-question">
<span class="dx-qa-label q">В</span>
@@ -497,6 +429,7 @@
</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-dark: #1e3c38;
$dx-primary: #497671;
@@ -50,7 +52,7 @@ $dx-card-bg: #f5f3f9;
transition: all 0.2s;
&:hover {
background: darken($dx-primary, 8%);
background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-1px);
}
}
@@ -281,7 +283,7 @@ $dx-card-bg: #f5f3f9;
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
&:hover {
background: darken($dx-primary, 8%);
background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(73, 118, 113, 0.3);
}
@@ -434,7 +436,7 @@ $dx-card-bg: #f5f3f9;
justify-content: center;
&:hover:not(:disabled) {
background: darken($dx-primary, 8%);
background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-1px);
}
@@ -642,70 +644,22 @@ $dx-card-bg: #f5f3f9;
}
}
// ========== 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
// Responsive
@media (max-width: 992px) {
.dx-item-content {
grid-template-columns: 1fr;
gap: 32px;
}
.dx-gallery {
max-width: 600px;
margin: 0 auto;
}
.dx-main-photo {
max-height: 480px;
aspect-ratio: auto;
}
.dx-add-cart {
max-width: 100%;
}
.dx-reviews-section,
.dx-qa-section {
h2 {
font-size: 1.3rem;
}
}
}
// Mobile
@media (max-width: 768px) {
.dx-item-container {
padding: 16px;
}
.dx-item-content {
gap: 24px;
margin-bottom: 32px;
}
// Thumbnails go below main photo
// On mobile: thumbnails go below main photo
.dx-gallery {
flex-direction: column;
max-width: 100%;
}
.dx-thumbnails {
@@ -714,16 +668,14 @@ $dx-card-bg: #f5f3f9;
max-height: none;
overflow-x: auto;
overflow-y: hidden;
order: 1;
order: 1; // put below main photo
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.dx-main-photo {
order: 0;
max-height: 400px;
aspect-ratio: auto;
order: 0; // main photo first
}
.dx-thumb {
@@ -740,32 +692,8 @@ $dx-card-bg: #f5f3f9;
font-size: 1.8rem;
}
.dx-old-price {
font-size: 1rem;
}
.dx-add-cart {
max-width: 100%;
padding: 14px 20px;
font-size: 1rem;
}
.dx-description {
h2 {
font-size: 1.15rem;
}
}
.dx-specs-table {
.spec-key {
white-space: normal;
width: auto;
}
td {
padding: 8px 10px;
font-size: 0.85rem;
}
}
.dx-review-form {
@@ -781,153 +709,21 @@ $dx-card-bg: #f5f3f9;
width: 100%;
}
}
.dx-review-card {
padding: 16px;
}
.dx-reviews-section,
.dx-qa-section {
margin-bottom: 32px;
h2 {
font-size: 1.25rem;
margin-bottom: 16px;
}
}
.dx-qa-card {
padding: 16px;
}
.dx-question,
.dx-answer {
font-size: 0.9rem;
}
}
// Small mobile
@media (max-width: 480px) {
.dx-item-container {
padding: 12px;
}
.dx-item-content {
gap: 20px;
margin-bottom: 24px;
}
.dx-main-photo {
max-height: 300px;
border-radius: 10px;
img, video {
padding: 8px;
}
}
.dx-thumb {
width: 52px;
height: 52px;
min-width: 52px;
width: 56px;
height: 56px;
min-width: 56px;
}
.dx-title {
font-size: 1.2rem;
}
.dx-info {
gap: 16px;
font-size: 1.25rem;
}
.dx-current-price {
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;
font-size: 1.6rem;
}
}
@@ -1505,20 +1301,12 @@ $dx-card-bg: #f5f3f9;
}
}
// ========== NOVO RESPONSIVE ==========
// Tablet portrait
@media (max-width: 968px) {
.novo-item-content {
grid-template-columns: 1fr;
gap: 2rem;
}
.novo-gallery {
max-width: 600px;
margin: 0 auto;
}
.novo-info .novo-title {
font-size: 1.5rem;
}
@@ -1527,10 +1315,6 @@ $dx-card-bg: #f5f3f9;
font-size: 2rem;
}
.novo-info .novo-add-cart {
max-width: 100%;
}
.novo-review-form {
padding: 1.5rem;
@@ -1545,302 +1329,3 @@ $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

@@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject }
import { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services';
import { Item, DescriptionField } from '../../models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ApiService, CartService, TelegramService, SeoService, LanguageService } from '../../services';
import { Item } from '../../models';
import { DomSanitizer } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
import { environment } from '../../../environments/environment';
import { SecurityContext } from '@angular/core';
import { getDiscountedPrice, getAllImages, getStockStatus, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
import { getDiscountedPrice } from '../../utils/item.utils';
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service';
@@ -42,14 +42,14 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
private seoService = inject(SeoService);
private i18n = inject(TranslateService);
private langService = inject(LanguageService);
constructor(
private route: ActivatedRoute,
private apiService: ApiService,
private cartService: CartService,
private telegramService: TelegramService,
private sanitizer: DomSanitizer,
private languageService: LanguageService
private sanitizer: DomSanitizer
) {}
ngOnInit(): void {
@@ -101,58 +101,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
return getDiscountedPrice(currentItem);
}
// BackOffice integration helpers
getItemName(): string {
const currentItem = this.item();
if (!currentItem) return '';
const lang = this.languageService.currentLanguage();
return getTranslatedField(currentItem, 'name', lang);
}
getSimpleDescription(): string {
const currentItem = this.item();
if (!currentItem) return '';
const lang = this.languageService.currentLanguage();
return getTranslatedField(currentItem, 'simpleDescription', lang);
}
hasDescriptionFields(): boolean {
const currentItem = this.item();
return !!(currentItem?.descriptionFields && currentItem.descriptionFields.length > 0);
}
getTranslatedDescriptionFields(): DescriptionField[] {
const currentItem = this.item();
if (!currentItem) return [];
const lang = this.languageService.currentLanguage();
const translation = currentItem.translations?.[lang];
if (translation?.description && translation.description.length > 0) {
return translation.description;
}
return currentItem.descriptionFields || [];
}
getStockClass(): string {
const currentItem = this.item();
if (!currentItem) return 'high';
return getStockStatus(currentItem);
}
getStockLabel(): string {
const status = this.getStockClass();
switch (status) {
case 'high': return 'В наличии';
case 'medium': return 'Заканчивается';
case 'low': return 'Последние штуки';
case 'out': return 'Нет в наличии';
default: return 'В наличии';
}
}
readonly getBadgeClass = getBadgeClass;
getSafeHtml(html: string): SafeHtml {
getSafeHtml(html: string): string {
return this.sanitizer.sanitize(SecurityContext.HTML, html) || '';
}
@@ -175,7 +124,9 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
if (diffDays < 7) return `${diffDays} ${this.i18n.t('itemDetail.daysAgo')}`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} ${this.i18n.t('itemDetail.weeksAgo')}`;
return date.toLocaleDateString('ru-RU', {
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', hy: 'hy-AM' };
const locale = localeMap[this.langService.currentLanguage()] || 'ru-RU';
return date.toLocaleDateString(locale, {
day: 'numeric',
month: 'long',
year: 'numeric'

View File

@@ -1,4 +1,4 @@
<h1>Company Details</h1>
<h1>Company Details</h1>
<section class="legal-section">
<h2>Full Company Name</h2>
@@ -14,7 +14,7 @@
<section class="legal-section">
<h2>Actual Address</h2>
<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>
<section class="legal-section">

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
<div class="legal-page">
<div class="legal-container">
<h1>PUBLIC OFFER AGREEMENT</h1>
<section class="legal-section">
@@ -77,7 +79,7 @@
<p>By placing orders through the marketplace, the User expresses full agreement with the terms of sale of goods and services defined in this agreement.</p>
<p><strong>3.2. Conclusion of Contracts</strong></p>
<p>A retail sales contract or a service agreement is concluded directly between the Seller and the Buyer from the moment the Seller issues cash or sales receipts confirming payment. The Marketplace acts as an information intermediary, providing infrastructure for transactions, but is not a party to this contract. Responsibility for the performance of the contract, the quality of goods and services rests with the Seller.</p>
<p>A retail sales contract or a service agreement is concluded directly between the Seller and the Buyer. The Site Owner is not a party (seller) to the said contracts, but merely provides the information and technical infrastructure (marketplace) for their conclusion and execution. The fact of payment for the Goods through the Site does not mean the transfer of any obligations under the transaction to the Site Owner.</p>
<p><strong>3.3. Consent to Contact Processing</strong></p>
<p>The User consents to the use of their contact details (email address, phone number) by the site administration and the Seller, as well as by engaged third parties, to fulfill obligations to the Buyer, including sending advertising and other information.</p>
@@ -175,6 +177,62 @@
<p><strong>6.3. User Rights</strong></p>
<p>The User has the right to refuse to receive advertising messages by using the appropriate tool on the site or by sending a request by email to <a href="mailto:info@dexarmarket.ru">info@dexarmarket.ru</a> or by letter to the official address of the Site Owner.</p>
<p><strong>6.4. Prohibited Goods for Sale on dexar.market</strong></p>
<p>The following goods and services are prohibited from being sold on the dexar.market platform:</p>
<ul>
<li>Weapons, ammunition, military equipment, spare parts, components and instruments thereto, explosives, detonation devices, all types of rocket fuel, as well as special materials and equipment for their production, special equipment of paramilitary organisations and regulatory documentation for their production and operation.</li>
<li>Rocket and space systems, military communication and control systems, and regulatory documentation for their production and operation.</li>
<li>Combat chemical agents, means of protection against them, and regulatory documentation for their production and use.</li>
<li>Results of research, design work, and fundamental research on the development of weapons and military equipment.</li>
<li>Services, works and materials related to military service and paramilitary activities.</li>
<li>Any weapons, including hunting, civilian and other, as well as components thereof, knives (except kitchen, penknife and office knives).</li>
<li>Radioactive substances and isotopes, uranium and other fissile materials and products made from them.</li>
<li>Radioactive material waste.</li>
<li>Precious and rare-earth metals, gemstones, as well as waste containing precious and rare-earth metals and gemstones.</li>
<li>X-ray equipment, instruments and equipment using radioactive substances and isotopes.</li>
<li>Poisons, narcotic drugs and psychotropic substances, their precursors.</li>
<li>Ethyl alcohol and alcoholic beverages.</li>
<li>Prescription medicines, as well as narcotic, psychotropic and alcohol-containing (with ethyl alcohol content above 25%) medicines and alcohol-based balsams.</li>
<li>Medicinal raw materials obtained from reindeer farming (velvet antlers and endocrine raw materials).</li>
<li>Tobacco products or vaping products.</li>
<li>Encryption equipment and regulatory documentation for its production and use.</li>
<li>Counterfeit currency.</li>
<li>Foreign currency and other currency valuables, coins and banknotes of the Russian Federation in circulation.</li>
<li>Radio-electronic and special technical means intended for covert collection of information, as well as high-frequency devices intended for transmitting and receiving radio waves above 8 GHz.</li>
<li>Materials and services that violate the privacy of personal life, encroach on the honour, dignity and business reputation of individuals and legal entities, or contain state, banking, commercial and other secrets.</li>
<li>State awards of the RF, RSFSR, USSR, and copies thereof.</li>
<li>Government identity documents, badges, passes, permits, certificates, travel documents and licences, as well as other documents granting rights or releasing from rights or obligations, and blanks for such documents.</li>
<li>Cultural heritage sites of the peoples of the Russian Federation, as well as archaeological heritage sites.</li>
<li>Human organs and tissues, as well as donor services.</li>
<li>Animals and plants listed in the Red Book of the Russian Federation and regional Red Books, parts and organs of such animals, as well as animals and plants protected by international treaties.</li>
<li>Hides and products made from hides of rare and endangered animal species.</li>
<li>Fishing nets, materials for their manufacture, manufacturing services, electric fishing rods and traps prohibited from sale in the Russian Federation.</li>
<li>Extremist materials, materials inciting mass riots, terrorist and extremist activities, participation in mass public events, incitement of ethnic and religious hatred.</li>
<li>Items bearing Nazi symbols or symbols of organisations banned in the Russian Federation.</li>
<li>Counterfeit or stolen goods or property.</li>
<li>Databases, including those containing personal data that may facilitate unauthorised mass mailings.</li>
<li>Materials transmitted exclusively in virtual form and not recorded on any physical medium (ideas, methods, principles, etc.).</li>
<li>Gaming equipment used for gambling, lottery equipment, online sports betting services, acceptance of payments for lottery tickets, and sale of virtual currency.</li>
<li>Vehicle documents, government licence plates for vehicles.</li>
<li>Goods whose circulation infringes the intellectual property rights of third parties (including patents, trademarks, copyrights, etc.).</li>
<li>Investment services, transactions with funds and cryptocurrencies, as well as goods and services whose purchase or use is guaranteed to generate earnings or profit.</li>
<li>Goods and services sold by multi-level network marketing organisations based on creating a network of independent distributors or sales agents.</li>
<li>Services and/or work of an intimate, erotic or sexual nature, as well as pornographic or erotic materials.</li>
<li>Goods or services whose use may be aimed at violating applicable legislation of the Russian Federation.</li>
<li>Non-existent goods or services, as well as goods or services with no consumer value.</li>
<li>Transcendental services and alternative medicine services.</li>
<li>Services for replacing licensed software or circumventing technical protection measures on phones, smartphones, laptops, navigators, personal computers, etc.</li>
<li>Other goods or services whose circulation is prohibited or restricted under Russian Federation legislation, or which may negatively affect the business reputation of international payment systems.</li>
<li>Injectable preparations and solutions, as well as substances used in their manufacture.</li>
<li>Services, works and materials related to the activities of occult organisations and sects.</li>
<li>Goods and services sold by companies organised as financial pyramids.</li>
<li>Antiques.</li>
<li>Dietary supplements. Sale of dietary supplements is permitted only through pharmacy institutions (pharmacies, pharmacy shops, pharmacy kiosks), specialised diet food shops, and grocery stores with dedicated departments.</li>
<li>Custom coursework and diploma papers.</li>
<li>Anonymous work (couriers for illegal deliveries, etc.).</li>
<li>Copies and replicas of original goods.</li>
</ul>
</section>
<section class="legal-section">
@@ -242,13 +300,11 @@
<p>The Seller bears full responsibility for the quality, safety and compliance of the products sold with the stated characteristics, as well as for losses caused by breach of obligations to the Buyer.</p>
<p><strong>9.7. Site Owner Liability and Claims Settlement Procedure</strong></p>
<p>The Site Owner is responsible for the quality, safety and accuracy of information about goods and services posted on the platform. At the same time, the Site Owner is not responsible for:</p>
<p>The Site Owner is responsible for the accuracy of general information about the platform and its operation, and also takes all possible measures to prevent the posting of knowingly false information about goods, if it becomes aware of such information. At the same time, the Site Owner is not responsible for:</p>
<ul>
<li>The fulfillment or improper fulfillment by Sellers of their obligations to Buyers.</li>
<li>Damage to third-party rights, including intellectual property.</li>
<li>Issues of delivery, completeness and condition of goods.</li>
<li>The compliance of the Seller's activities with legal requirements, the availability of necessary permits and licenses, as well as the correctness of calculation and payment of taxes by the Seller.</li>
</ul>
<p>The Buyer agrees that claims regarding the quality, quantity, completeness of goods and services may be directed to both the Seller and the Site Administration. The Site Administration assumes responsibility for the quality and accuracy of services provided through the platform and actively participates in the settlement of disputes.</p>
<p>The Buyer agrees that claims regarding the quality, quantity, completeness of goods and services may be directed to both the Seller and the Site Administration.</p>
<p><strong>9.8. Delivery Liability</strong></p>
<p>Responsibility for the timing, conditions and quality of goods delivery is borne by transport companies and courier services. The Site Owner acts only as an information intermediary and is not responsible for the actions of delivery services.</p>
@@ -384,7 +440,7 @@
<p>When purchasing a set of products on promotion with a discount, return or exchange is possible only as a complete set. Individual products from the set cannot be returned.</p>
<p><strong>13.5. Delivery Cost Compensation</strong></p>
<p>When returning a product of proper quality, the Seller or the Site Owner has the right to recover delivery costs from the Buyer.</p>
<p>When returning a product of proper quality, the cost of delivering the product from the Buyer back to the Seller is borne by the Buyer, unless otherwise provided by law or established by the Seller. The Site Owner does not compensate such costs.</p>
<p><strong>13.6. Claim Satisfaction Terms</strong></p>
<p>Refund applications are satisfied within 10 days from the date of submission.</p>
@@ -459,3 +515,5 @@
<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>
</section>
</div>
</div>

View File

@@ -1,3 +1,5 @@
<div class="legal-page">
<div class="legal-container">
<h1>СОГЛАШЕНИЕ ПУБЛИЧНОЙ ОФЕРТЫ</h1>
<section class="legal-section">
@@ -77,7 +79,7 @@
<p>Пользователь, делая заказы через маркетплейс, выражает полное согласие с условиями продажи товаров и оказания услуг, определёнными в данном соглашении.</p>
<p><strong>3.2. Заключение договоров</strong></p>
<p>Договор розничной купли-продажи или договор оказания услуг заключается непосредственно между Продавцом и Покупателем с момента выдачи Продавцом кассовых или товарных чеков, подтверждающих оплату. Маркетплейс играет роль информационного посредника, предоставляя инфраструктуру для совершения сделок, но не является участником данного договора. Ответственность за выполнение договора, качество товаров и услуг лежит на Продавце.</p>
<p>Договор розничной купли-продажи или договор оказания услуг заключается непосредственно между Продавцом и Покупателем. Владелец сайта не является стороной (продавцом) по указанным договорам, а лишь предоставляет информационно-техническую инфраструктуру (маркетплейс) для их заключения и исполнения. Факт оплаты Товара через Сайт не означает переход каких-либо обязательств по сделке на Владельца сайта.</p>
<p><strong>3.3. Согласие на обработку контактов</strong></p>
<p>Пользователь даёт согласие на использование его контактных данных (адрес электронной почты, номер телефона) администрацией сайта и Продавцом, а также привлечёнными третьими сторонами для выполнения обязательств перед Покупателем, включая рассылки рекламной и иной информации.</p>
@@ -175,6 +177,62 @@
<p><strong>6.3. Права Пользователя</strong></p>
<p>Пользователь имеет право отказаться от получения рекламных сообщений, воспользовавшись соответствующим инструментом на сайте или направив заявку по электронной почте <a href="mailto:info@dexarmarket.ru">info@dexarmarket.ru</a> или письмом по официальному адресу Владельца сайта.</p>
<p><strong>6.4. Запрещённые товары для продажи на dexar.market</strong></p>
<p>На платформе dexar.market запрещена продажа следующих товаров и услуг:</p>
<ul>
<li>Вооружение, боеприпасы к нему, военная техника, запасные части, комплектующие изделия и приборы к ним, взрывчатые вещества, средства взрывания, все виды ракетного топлива, а также специальные материалы и специальное оборудование для их производства, специальное снаряжение военизированных организаций и нормативно-техническая продукция на их производство и эксплуатацию.</li>
<li>Ракетно-космические комплексы, системы связи и управления военного назначения и нормативно-техническая документация на их производство и эксплуатацию.</li>
<li>Боевые отравляющие вещества, средства защиты от них и нормативно-техническая документация на их производство и использование.</li>
<li>Результаты научно-исследовательских и проектных работ, а также фундаментальных поисковых исследований по созданию вооружения и военной техники.</li>
<li>Услуги, работы и материалы, связанные с осуществлением военной службы и военизированной деятельности.</li>
<li>Любое оружие, в том числе охотничье, гражданское и иное, а также комплектующие изделия к нему, ножи (за исключением кухонных, перочинных и канцелярских).</li>
<li>Радиоактивные вещества и изотопы, уран и другие делящиеся материалы и изделия из них.</li>
<li>Отходы радиоактивных материалов.</li>
<li>Драгоценные и редкоземельные металлы, драгоценные камни, а также отходы, содержащие драгоценные и редкоземельные металлы и драгоценные камни.</li>
<li>Рентгеновское оборудование, приборы и оборудование с использованием радиоактивных веществ и изотопов.</li>
<li>Яды, наркотические средства и психотропные вещества, их прекурсоры.</li>
<li>Спирт этиловый, алкогольные напитки.</li>
<li>Лекарственные препараты, отпускаемые по рецепту, а также наркотические, психотропные и спиртосодержащие (с объёмной долей этилового спирта свыше 25%) лекарственные препараты и бальзамы на основе спирта.</li>
<li>Лекарственное сырьё, получаемое от северного оленеводства (панты и эндокринное сырьё).</li>
<li>Табачная продукция или продукты для вейпинга.</li>
<li>Шифровальная техника и нормативно-техническая документация на её производство и использование.</li>
<li>Поддельные денежные знаки.</li>
<li>Иностранная валюта и иные валютные ценности, монеты и банкноты Российской Федерации, находящиеся в обращении.</li>
<li>Радиоэлектронные и специальные технические средства, предназначенные для негласного получения информации, а также высокочастотные устройства, предназначенные для передачи и приёма радиоволн на частоте выше 8 ГГц.</li>
<li>Материалы и услуги, нарушающие тайну частной жизни, посягающие на честь, достоинство и деловую репутацию граждан и юридических лиц, а также содержащие государственную, банковскую, коммерческую и иную тайны.</li>
<li>Государственные награды РФ, РСФСР, СССР, а также их копии.</li>
<li>Государственные удостоверения личности, знаки, пропуска, разрешения, сертификаты, проездные документы и лицензии, а также иные документы, предоставляющие права или освобождающие от прав или обязанностей, бланки для этих документов, а также услуги по их получению.</li>
<li>Объекты культурного наследия народов Российской Федерации, а также объекты археологического наследия.</li>
<li>Человеческие органы и ткани, а также донорские услуги.</li>
<li>Животные и растения, занесённые в Красную книгу Российской Федерации и Красные книги субъектов Российской Федерации, части и органы таких животных, а также животные и растения, охраняемые международными договорами.</li>
<li>Шкуры и изделия из шкур редких и находящихся под угрозой исчезновения видов животных.</li>
<li>Рыболовные сети, материалы для их изготовления, а также услуги по их изготовлению, электроудочки и капканы, запрещённые к реализации на территории Российской Федерации.</li>
<li>Экстремистские материалы, материалы, призывающие к массовым беспорядкам, осуществлению террористической и экстремистской деятельности, к участию в массовых публичных мероприятиях, разжиганию межнациональной и межконфессиональной розни.</li>
<li>Предметы с нацистской символикой или символикой запрещённых в Российской Федерации организаций.</li>
<li>Контрафактная или краденая продукция, или имущество.</li>
<li>Базы данных, в том числе содержащие персональные данные, которые могут способствовать несанкционированным рассылкам.</li>
<li>Материалы, передаваемые исключительно виртуально и не записанные на какой-либо материальный носитель (идеи, методы, принципы и т. д.).</li>
<li>Игровое оборудование, используемое для проведения азартных игр, лотерейное оборудование, оказание услуг по приёму ставок для участия в азартных играх в интернете, приём платежей за лотерейные билеты, а также продажа виртуальной валюты.</li>
<li>Документы на транспортные средства, государственные номера для транспортных средств.</li>
<li>Товары, оборот которых нарушает интеллектуальные права третьих лиц (в том числе патенты, товарные знаки, авторские права и т. д.).</li>
<li>Инвестиционные услуги, операции с денежными средствами и криптовалютами, а также товары и услуги, приобретение или использование которых гарантированно приносит заработок или прибыль.</li>
<li>Товары и услуги, реализуемые организацией многоуровневого сетевого маркетинга, деятельность которых основана на создании сети независимых дистрибьюторов или сбытовых агентов.</li>
<li>Услуги и (или) работа интимного, эротического или сексуального характера, а также порнографические или эротические материалы.</li>
<li>Товары или услуги, использование которых может быть направлено на нарушение действующего законодательства Российской Федерации.</li>
<li>Несуществующие товары или услуги, а также товары или услуги, не имеющие потребительской ценности.</li>
<li>Трансцендентные услуги и услуги нетрадиционной медицины.</li>
<li>Услуги по замене лицензионного программного обеспечения или нарушению работы установленных правообладателем средств технической защиты телефонов, смартфонов, ноутбуков, навигаторов, персональных компьютеров и т. д.</li>
<li>Иные товары или услуги, оборот которых запрещён или ограничен согласно законодательству Российской Федерации, а также способен оказать негативное влияние на деловую репутацию международных платёжных систем.</li>
<li>Инъекционные препараты и растворы, а также вещества, применяемые для их изготовления.</li>
<li>Услуги, работы и материалы, связанные с осуществлением деятельности оккультных организаций и сект.</li>
<li>Товары и услуги, реализуемые компаниями по форме организации финансовых пирамид.</li>
<li>Антиквариат.</li>
<li>Биологически активные добавки. Продажа БАД к пище возможна только через аптечные учреждения (аптеки, аптечные магазины, аптечные киоски), специализированные магазины с диетическими продуктами, продовольственные магазины со специальными отделами и секциями.</li>
<li>Курсовые и дипломы на заказ.</li>
<li>Анонимная работа (закладчики и т. п.).</li>
<li>Копии и реплики оригинальных товаров.</li>
</ul>
</section>
<section class="legal-section">
@@ -242,13 +300,11 @@
<p>Продавец несёт полную ответственность за качество, безопасность и соответствие реализуемой продукции заявленным характеристикам, а также за убытки, вызванные нарушением обязательств перед Покупателем.</p>
<p><strong>9.7. Ответственность Владельца сайта и порядок урегулирования претензий</strong></p>
<p>Владелец сайта несёт ответственность за качество, безопасность и достоверность информации о товарах и услугах, размещённых на платформе. При этом Владелец сайта не несёт ответственности за:</p>
<p>Владелец сайта несет ответственность за достоверность общей информации о платформе и ее функционировании, а также принимает все возможные меры для недопущения размещения заведомо ложной информации о товарах, если о таковой ему стало известно. При этом Владелец сайта не несёт ответственности за:</p>
<ul>
<li>Выполнение или ненадлежащее выполнение Продавцами своих обязательств перед Покупателями.</li>
<li>Повреждение прав третьих лиц, включая интеллектуальную собственность.</li>
<li>Вопросы доставки, комплектации и состояния товаров.</li>
<li>Соответствие деятельности Продавца требованиям законодательства, наличие у него необходимых разрешений и лицензий, а также за правильность исчисления и уплаты им налогов.</li>
</ul>
<p>Покупатель соглашается, что претензии по качеству, количеству, комплектности товаров и услугам могут быть направлены как Продавцу, так и Администрации сайта. Администрация сайта принимает на себя ответственность за качество и достоверность услуг, предоставляемых через платформу, и активно участвует в урегулировании спорных ситуаций.</p>
<p>Покупатель соглашается, что претензии по качеству, количеству, комплектности товаров и услугам могут быть направлены как Продавцу, так и Администрации сайта.</p>
<p><strong>9.8. Ответственность за доставку</strong></p>
<p>Ответственность за сроки, условия и качество доставки товаров несут транспортные компании и курьерские службы. Владелец сайта выступает только в качестве информационного посредника и не несёт ответственности за действия служб доставки.</p>
@@ -384,7 +440,7 @@
<p>При приобретении набора товаров по акции с предоставлением скидки возврат или обмен возможен только в комплексе. Отдельные товары из комплекта вернуть нельзя.</p>
<p><strong>13.5. Компенсация затрат на доставку</strong></p>
<p>При возврате качественного товара Продавец или Владелец сайта вправе взыскать с Покупателя затраты на доставку товара.</p>
<p>При возврате товара надлежащего качества расходы на доставку товара от Покупателя обратно Продавцу несет Покупатель, если иное не предусмотрено законодательством или не установлено Продавцом. Владелец сайта не компенсирует такие расходы.</p>
<p><strong>13.6. Сроки удовлетворения требований</strong></p>
<p>Заявления о возврате средств удовлетворяются в течение 10 дней с момента подачи.</p>
@@ -459,3 +515,5 @@
<p><strong>16.8. Реакция на нарушения</strong></p>
<p>Невмешательство Владельца сайта в случае нарушений соглашений Пользователями не препятствует последующим мерам защиты интересов Владельца позже.</p>
</section>
</div>
</div>

View File

@@ -56,29 +56,18 @@
@if (items().length > 0) {
<div class="items-grid">
@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">
<div class="item-image">
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" width="300" height="300" />
@if (item.discount > 0) {
<div class="discount-badge">-{{ item.discount }}%</div>
}
@if (item.badges && item.badges.length > 0) {
<div class="item-badges-overlay">
@for (badge of item.badges; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
</div>
<div class="item-details">
<h3 class="item-name">{{ item.name }}</h3>
@if (item.simpleDescription) {
<p class="item-simple-desc">{{ item.simpleDescription }}</p>
}
<div class="item-rating">
<span class="rating-stars">⭐ {{ item.rating }}</span>
<span class="rating-count">({{ item.callbacks?.length || 0 }})</span>
@@ -105,19 +94,29 @@
</div>
</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 }}
</button>
</div>
}
</div>
@if (loading() && items().length > 0) {
<div class="loading-more">
<div class="spinner"></div>
<p>{{ 'search.loadingMore' | translate }}</p>
</div>
}
@if (loading() && items().length > 0) {
@for (i of skeletonSlots; track i) {
<div class="item-card skeleton-card">
<div class="item-link">
<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) {
<div class="no-more">

View File

@@ -344,6 +344,59 @@
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) {
.search-header h1 {
font-size: 1.5rem;

View File

@@ -3,13 +3,15 @@ import { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { ApiService, CartService } from '../../services';
import { PrefetchService } from '../../services/prefetch.service';
import { Item } from '../../models';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass } from '../../utils/item.utils';
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service';
import { SEARCH_DEBOUNCE_MS, ITEMS_PER_PAGE, SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS } from '../../config/constants';
@Component({
selector: 'app-search',
@@ -27,7 +29,7 @@ export class SearchComponent implements OnDestroy {
totalResults = signal<number>(0);
private skip = 0;
private readonly count = 20;
private readonly count = ITEMS_PER_PAGE;
private isLoadingMore = false;
private searchSubject = new Subject<string>();
private searchSubscription: Subscription;
@@ -35,11 +37,12 @@ export class SearchComponent implements OnDestroy {
constructor(
private apiService: ApiService,
private cartService: CartService
private cartService: CartService,
private prefetchService: PrefetchService
) {
this.searchSubscription = this.searchSubject
.pipe(
debounceTime(300),
debounceTime(SEARCH_DEBOUNCE_MS),
distinctUntilChanged()
)
.subscribe(query => {
@@ -63,7 +66,7 @@ export class SearchComponent implements OnDestroy {
performSearch(query: string): void {
if (!query.trim()) {
this.items.set([]);
this.hasMore.set(true);
this.hasMore.set(false);
this.totalResults.set(0);
return;
}
@@ -119,12 +122,12 @@ export class SearchComponent implements OnDestroy {
this.scrollTimeout = setTimeout(() => {
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.loadResults();
}
}, 100);
}, SCROLL_DEBOUNCE_MS);
}
addToCart(itemID: number, event: Event): void {
@@ -133,8 +136,12 @@ export class SearchComponent implements OnDestroy {
this.cartService.addItem(itemID);
}
onItemHover(itemID: number): void {
this.prefetchService.prefetchItem(itemID);
}
readonly skeletonSlots = Array.from({ length: 8 });
readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId;
readonly getBadgeClass = getBadgeClass;
}

View File

@@ -7,19 +7,30 @@ import { LanguageService } from '../services/language.service';
})
export class LangRoutePipe implements PipeTransform {
private langService = inject(LanguageService);
private lastLang = '';
private lastInput: unknown = null;
private lastResult: string | (string | number)[] = '';
transform(value: string | (string | number)[]): string | (string | number)[] {
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') {
return value === '/' ? `/${lang}` : `/${lang}${value}`;
}
if (Array.isArray(value) && value.length > 0) {
this.lastResult = value === '/' ? `/${lang}` : `/${lang}${value}`;
} else if (Array.isArray(value) && value.length > 0) {
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,8 +1,8 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Category, Item, Subcategory } from '../models';
import { Observable, timer } from 'rxjs';
import { map, retry } from 'rxjs/operators';
import { Category, Item } from '../models';
import { environment } from '../../environments/environment';
@Injectable({
@@ -11,142 +11,46 @@ import { environment } from '../../environments/environment';
export class ApiService {
private readonly baseUrl = environment.apiUrl;
private readonly retryConfig = {
count: 2,
delay: (error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
};
constructor(private http: HttpClient) {}
/**
* Normalize an item from the API response — supports both
* legacy marketplace format and the new backOffice API format.
*/
private normalizeItem(raw: any): Item {
const item: Item = { ...raw };
// Map backOffice string id → legacy numeric itemID
if (raw.id != null && raw.itemID == null) {
item.id = String(raw.id);
item.itemID = typeof raw.id === 'number' ? raw.id : 0;
}
// Map backOffice imgs[] → legacy photos[]
if (raw.imgs && (!raw.photos || raw.photos.length === 0)) {
item.photos = raw.imgs.map((url: string) => ({ url }));
}
item.imgs = raw.imgs || raw.photos?.map((p: any) => p.url) || [];
// Map backOffice description (key-value array) → legacy description string
if (Array.isArray(raw.description)) {
item.descriptionFields = raw.description;
item.description = raw.description.map((d: any) => `${d.key}: ${d.value}`).join('\n');
} else {
item.description = raw.description || raw.simpleDescription || '';
}
// Map backOffice comments → legacy callbacks
if (raw.comments && (!raw.callbacks || raw.callbacks.length === 0)) {
item.callbacks = raw.comments.map((c: any) => ({
rating: c.stars,
content: c.text,
userID: c.author,
timestamp: c.createdAt,
}));
}
item.comments = raw.comments || raw.callbacks?.map((c: any) => ({
id: c.userID,
text: c.content,
author: c.userID,
stars: c.rating,
createdAt: c.timestamp,
})) || [];
// Compute average rating from comments if not present
if (raw.rating == null && item.comments && item.comments.length > 0) {
const rated = item.comments.filter(c => c.stars != null);
item.rating = rated.length > 0
? rated.reduce((sum, c) => sum + (c.stars || 0), 0) / rated.length
: 0;
}
item.rating = item.rating || 0;
// Defaults
item.discount = item.discount || 0;
item.remainings = item.remainings || (raw.quantity != null
? (raw.quantity <= 0 ? 'out' : raw.quantity <= 5 ? 'low' : raw.quantity <= 20 ? 'medium' : 'high')
: 'high');
item.currency = item.currency || 'RUB';
// Preserve new backOffice fields
item.badges = raw.badges || [];
item.tags = raw.tags || [];
item.simpleDescription = raw.simpleDescription || '';
item.translations = raw.translations || {};
item.visible = raw.visible ?? true;
item.priority = raw.priority ?? 0;
return item;
private normalizeItem(item: Item): Item {
return {
...item,
remainings: item.remainings || 'high'
};
}
private normalizeItems(items: any[] | null | undefined): Item[] {
private normalizeItems(items: Item[] | null | undefined): Item[] {
if (!items || !Array.isArray(items)) {
return [];
}
return items.map(item => this.normalizeItem(item));
}
/**
* Normalize a category from the API response — supports both
* the flat legacy format and nested backOffice format.
*/
private normalizeCategory(raw: any): Category {
const cat: Category = { ...raw };
if (raw.id != null && raw.categoryID == null) {
cat.id = String(raw.id);
cat.categoryID = typeof raw.id === 'number' ? raw.id : 0;
}
// Map backOffice img → legacy icon
if (raw.img && !raw.icon) {
cat.icon = raw.img;
}
cat.img = raw.img || raw.icon;
cat.parentID = raw.parentID ?? 0;
cat.visible = raw.visible ?? true;
cat.priority = raw.priority ?? 0;
if (raw.subcategories && Array.isArray(raw.subcategories)) {
cat.subcategories = raw.subcategories;
}
return cat;
}
private normalizeCategories(cats: any[] | null | undefined): Category[] {
if (!cats || !Array.isArray(cats)) return [];
return cats.map(c => this.normalizeCategory(c));
}
// ─── Core Marketplace Endpoints ───────────────────────────
ping(): Observable<{ message: string }> {
return this.http.get<{ message: string }>(`${this.baseUrl}/ping`);
}
getCategories(): Observable<Category[]> {
return this.http.get<any[]>(`${this.baseUrl}/category`)
.pipe(map(cats => this.normalizeCategories(cats)));
return this.http.get<Category[]>(`${this.baseUrl}/category`).pipe(retry(this.retryConfig));
}
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
const params = new HttpParams()
.set('count', count.toString())
.set('skip', skip.toString());
return this.http.get<any[]>(`${this.baseUrl}/category/${categoryID}`, { params })
.pipe(map(items => this.normalizeItems(items)));
return this.http.get<Item[]>(`${this.baseUrl}/category/${categoryID}`, { params })
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
}
getItem(itemID: number): Observable<Item> {
return this.http.get<any>(`${this.baseUrl}/item/${itemID}`)
.pipe(map(item => this.normalizeItem(item)));
return this.http.get<Item>(`${this.baseUrl}/item/${itemID}`)
.pipe(retry(this.retryConfig), map(item => this.normalizeItem(item)));
}
searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> {
@@ -154,8 +58,9 @@ export class ApiService {
.set('search', search)
.set('count', count.toString())
.set('skip', skip.toString());
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
return this.http.get<{ items: Item[], total: number, count: number, skip: number }>(`${this.baseUrl}/searchitems`, { params })
.pipe(
retry(this.retryConfig),
map(response => ({
items: this.normalizeItems(response?.items || []),
total: response?.total || 0
@@ -176,7 +81,7 @@ export class ApiService {
}
getCart(): Observable<Item[]> {
return this.http.get<any[]>(`${this.baseUrl}/cart`)
return this.http.get<Item[]>(`${this.baseUrl}/cart`)
.pipe(map(items => this.normalizeItems(items)));
}
@@ -262,7 +167,7 @@ export class ApiService {
if (categoryID) {
params = params.set('category', categoryID.toString());
}
return this.http.get<any[]>(`${this.baseUrl}/randomitems`, { params })
.pipe(map(items => this.normalizeItems(items)));
return this.http.get<Item[]>(`${this.baseUrl}/randomitems`, { params })
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
}
}

View File

@@ -1,128 +0,0 @@
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, catchError, map, tap } from 'rxjs';
import { AuthSession, AuthStatus } from '../models/auth.model';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private sessionSignal = signal<AuthSession | null>(null);
private statusSignal = signal<AuthStatus>('unknown');
private showLoginSignal = signal(false);
/** Current auth session */
readonly session = this.sessionSignal.asReadonly();
/** Current auth status */
readonly status = this.statusSignal.asReadonly();
/** Whether user is fully authenticated */
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
/** Whether to show login dialog */
readonly showLoginDialog = this.showLoginSignal.asReadonly();
/** Display name of authenticated user */
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
private readonly apiUrl = environment.apiUrl;
private sessionCheckTimer?: ReturnType<typeof setInterval>;
constructor(private http: HttpClient) {
// On init, check existing session via cookie
this.checkSession();
}
/**
* Check current session status with backend.
* The backend reads the session cookie and returns the session info.
*/
checkSession(): void {
this.statusSignal.set('checking');
this.http.get<AuthSession>(`${this.apiUrl}/auth/session`, {
withCredentials: true
}).pipe(
catchError(() => {
this.statusSignal.set('unauthenticated');
this.sessionSignal.set(null);
return of(null);
})
).subscribe(session => {
if (session && session.active) {
this.sessionSignal.set(session);
this.statusSignal.set('authenticated');
this.scheduleSessionRefresh(session.expiresAt);
} else if (session && !session.active) {
this.sessionSignal.set(null);
this.statusSignal.set('expired');
} else {
this.statusSignal.set('unauthenticated');
}
});
}
/**
* Called after user completes Telegram login.
* The callback URL from Telegram will hit our backend which sets the cookie.
* Then we re-check the session.
*/
onTelegramLoginComplete(): void {
this.checkSession();
this.hideLogin();
}
/** Generate the Telegram login URL for bot-based auth */
getTelegramLoginUrl(): string {
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'dexarmarket_bot';
const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`);
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
}
/** Get QR code data URL for Telegram login */
getTelegramQrUrl(): string {
return this.getTelegramLoginUrl();
}
/** Show login dialog (called when user tries to pay without being logged in) */
requestLogin(): void {
this.showLoginSignal.set(true);
}
/** Hide login dialog */
hideLogin(): void {
this.showLoginSignal.set(false);
}
/** Logout — clears session on backend and locally */
logout(): void {
this.http.post(`${this.apiUrl}/auth/logout`, {}, {
withCredentials: true
}).pipe(
catchError(() => of(null))
).subscribe(() => {
this.sessionSignal.set(null);
this.statusSignal.set('unauthenticated');
this.clearSessionRefresh();
});
}
/** Schedule a session re-check before it expires */
private scheduleSessionRefresh(expiresAt: string): void {
this.clearSessionRefresh();
const expiresMs = new Date(expiresAt).getTime();
const nowMs = Date.now();
// Re-check 60 seconds before expiry, minimum 30s from now
const refreshIn = Math.max(expiresMs - nowMs - 60_000, 30_000);
this.sessionCheckTimer = setTimeout(() => {
this.checkSession();
}, refreshIn);
}
private clearSessionRefresh(): void {
if (this.sessionCheckTimer) {
clearTimeout(this.sessionCheckTimer);
this.sessionCheckTimer = undefined;
}
}
}

View File

@@ -13,6 +13,7 @@ export class CartService {
private cartItems = signal<CartItem[]>([]);
private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp;
private addingItems = new Set<number>();
private initialized = false;
items = this.cartItems.asReadonly();
itemCount = computed(() => {
@@ -31,10 +32,12 @@ export class CartService {
constructor(private apiService: ApiService) {
this.loadCart();
// Auto-save whenever cart changes
// Auto-save whenever cart changes (skip the initial empty state)
effect(() => {
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
this.loadFromLocalStorage();
}
this.initialized = true;
});
} else {
this.loadFromLocalStorage();
this.initialized = true;
}
}

View File

@@ -3,5 +3,3 @@ export * from './cart.service';
export * from './telegram.service';
export * from './language.service';
export * from './seo.service';
export * from './location.service';
export * from './auth.service';

View File

@@ -17,8 +17,8 @@ export class LanguageService {
languages: Language[] = [
{ code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true },
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: true },
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: true }
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: false },
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: false }
];
currentLanguage = this.currentLanguageSignal.asReadonly();

View File

@@ -1,135 +0,0 @@
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Region, GeoIpResponse } from '../models/location.model';
import { environment } from '../../environments/environment';
const STORAGE_KEY = 'selected_region';
@Injectable({
providedIn: 'root'
})
export class LocationService {
private regionSignal = signal<Region | null>(null);
private regionsSignal = signal<Region[]>([]);
private loadingSignal = signal(false);
private detectedSignal = signal(false);
/** Current selected region (null = global / all regions) */
readonly region = this.regionSignal.asReadonly();
/** All available regions */
readonly regions = this.regionsSignal.asReadonly();
/** Whether geo-detection is in progress */
readonly detecting = this.loadingSignal.asReadonly();
/** Whether region was auto-detected */
readonly autoDetected = this.detectedSignal.asReadonly();
/** Computed region id for API calls — empty string means global */
readonly regionId = computed(() => this.regionSignal()?.id ?? '');
private readonly apiUrl = environment.apiUrl;
constructor(private http: HttpClient) {
this.loadRegions();
this.restoreFromStorage();
}
/** Fetch available regions from backend */
loadRegions(): void {
this.http.get<Region[]>(`${this.apiUrl}/regions`).subscribe({
next: (regions) => {
this.regionsSignal.set(regions);
// If we have a stored region, validate it still exists
const stored = this.regionSignal();
if (stored && !regions.find(r => r.id === stored.id)) {
this.clearRegion();
}
},
error: () => {
// Fallback: hardcoded popular regions
this.regionsSignal.set(this.getFallbackRegions());
}
});
}
/** Set region by user choice */
setRegion(region: Region): void {
this.regionSignal.set(region);
localStorage.setItem(STORAGE_KEY, JSON.stringify(region));
}
/** Clear region (go global) */
clearRegion(): void {
this.regionSignal.set(null);
localStorage.removeItem(STORAGE_KEY);
}
/** Auto-detect user location via IP geolocation */
detectLocation(): void {
if (this.detectedSignal()) return; // already tried
this.loadingSignal.set(true);
// Using free ip-api.com — no key required, 45 req/min
this.http.get<GeoIpResponse>('http://ip-api.com/json/?fields=city,country,countryCode,region,timezone,lat,lon')
.subscribe({
next: (geo) => {
this.detectedSignal.set(true);
this.loadingSignal.set(false);
// Only auto-set if user hasn't manually chosen a region
if (!this.regionSignal()) {
const matchedRegion = this.findRegionByGeo(geo);
if (matchedRegion) {
this.setRegion(matchedRegion);
}
}
},
error: () => {
this.detectedSignal.set(true);
this.loadingSignal.set(false);
}
});
}
/** Try to match detected geo data to an available region */
private findRegionByGeo(geo: GeoIpResponse): Region | null {
const regions = this.regionsSignal();
if (!regions.length) return null;
// Exact city match
const cityMatch = regions.find(r =>
r.city.toLowerCase() === geo.city?.toLowerCase()
);
if (cityMatch) return cityMatch;
// Country match — pick the first region for that country
const countryMatch = regions.find(r =>
r.countryCode.toLowerCase() === geo.countryCode?.toLowerCase()
);
return countryMatch || null;
}
/** Restore previously selected region from storage */
private restoreFromStorage(): void {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const region: Region = JSON.parse(stored);
this.regionSignal.set(region);
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
/** Fallback regions if backend /regions endpoint is unavailable */
private getFallbackRegions(): Region[] {
return [
{ id: 'moscow', city: 'Москва', country: 'Россия', countryCode: 'RU', timezone: 'Europe/Moscow' },
{ id: 'spb', city: 'Санкт-Петербург', country: 'Россия', countryCode: 'RU', timezone: 'Europe/Moscow' },
{ id: 'yerevan', city: 'Ереван', country: 'Армения', countryCode: 'AM', timezone: 'Asia/Yerevan' },
{ id: 'minsk', city: 'Минск', country: 'Беларусь', countryCode: 'BY', timezone: 'Europe/Minsk' },
{ id: 'almaty', city: 'Алматы', country: 'Казахстан', countryCode: 'KZ', timezone: 'Asia/Almaty' },
{ id: 'tbilisi', city: 'Тбилиси', country: 'Грузия', countryCode: 'GE', timezone: 'Asia/Tbilisi' },
];
}
}

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 { environment } from '../../environments/environment';
import { Item } from '../models';
@@ -10,6 +10,7 @@ import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
export class SeoService {
private meta = inject(Meta);
private title = inject(Title);
private doc = inject(DOCUMENT);
private readonly siteUrl = `https://${environment.domain}`;
private readonly siteName = environment.brandFullName;
@@ -25,6 +26,7 @@ export class SeoService {
const titleText = `${item.name}${this.siteName}`;
this.title.setTitle(titleText);
this.setCanonical(itemUrl);
this.setOrUpdate([
// Open Graph
@@ -81,6 +83,7 @@ export class SeoService {
// Remove product-specific tags
this.meta.removeTag("property='product:price:amount'");
this.meta.removeTag("property='product:price:currency'");
this.removeCanonical();
}
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 || '';
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,78 +1,13 @@
import { Item } from '../models';
export function getDiscountedPrice(item: Item): number {
return item.price * (1 - (item.discount || 0) / 100);
return item.price * (1 - item.discount / 100);
}
export function getMainImage(item: Item): string {
// Support both backOffice format (imgs: string[]) and legacy (photos: Photo[])
if (item.imgs && item.imgs.length > 0) {
return item.imgs[0];
}
return item.photos?.[0]?.url || '/assets/images/placeholder.svg';
}
export function getAllImages(item: Item): string[] {
if (item.imgs && item.imgs.length > 0) {
return item.imgs;
}
return item.photos?.map(p => p.url) || [];
}
export function trackByItemId(index: number, item: Item): number | string {
return item.id || item.itemID;
}
/**
* Get the display description — supports both legacy HTML string
* and structured key-value pairs from backOffice API.
*/
export function hasStructuredDescription(item: Item): boolean {
return Array.isArray(item.descriptionFields) && item.descriptionFields.length > 0;
}
/**
* Compute stock status from quantity if the legacy `remainings` field is absent.
*/
export function getStockStatus(item: Item): string {
if (item.remainings) return item.remainings;
if (item.quantity == null) return 'high';
if (item.quantity <= 0) return 'out';
if (item.quantity <= 5) return 'low';
if (item.quantity <= 20) return 'medium';
return 'high';
}
/**
* Map backOffice badge names to CSS color classes.
*/
export function getBadgeClass(badge: string): string {
const map: Record<string, string> = {
'new': 'badge-new',
'sale': 'badge-sale',
'exclusive': 'badge-exclusive',
'hot': 'badge-hot',
'limited': 'badge-limited',
'bestseller': 'badge-bestseller',
'featured': 'badge-featured',
};
return map[badge.toLowerCase()] || 'badge-custom';
}
/**
* Get the translated name/description for the current language.
* Falls back to the default (base) field if no translation exists.
*/
export function getTranslatedField(
item: Item,
field: 'name' | 'simpleDescription',
lang: string
): string {
const translation = item.translations?.[lang];
if (translation && translation[field]) {
return translation[field]!;
}
if (field === 'name') return item.name;
if (field === 'simpleDescription') return item.simpleDescription || item.description || '';
return '';
export function trackByItemId(index: number, item: Item): number {
return item.itemID;
}

View File

@@ -10,10 +10,8 @@ export const environment = {
supportEmail: 'info@novo.market',
domain: 'novo.market',
telegram: '@novomarket',
telegramBot: 'novomarket_bot',
phones: {
armenia: '+374 98 731231',
support: '+374 98 731231'
},
useMockData: false
}
};

View File

@@ -10,10 +10,8 @@ export const environment = {
supportEmail: 'info@novo.market',
domain: 'novo.market',
telegram: '@novomarket',
telegramBot: 'novomarket_bot',
phones: {
armenia: '+374 98 731231',
support: '+374 98 731231'
},
useMockData: true
}
};

View File

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

View File

@@ -1,7 +1,6 @@
// Dexar Market Configuration
export const environment = {
production: false,
useMockData: true, // Toggle to test with backOffice mock data
brandName: 'Dexarmarket',
brandFullName: 'Dexar Market',
theme: 'dexar',
@@ -11,7 +10,6 @@ export const environment = {
supportEmail: 'info@dexarmarket.ru',
domain: 'dexarmarket.ru',
telegram: '@dexarmarket',
telegramBot: 'dexarmarket_bot',
phones: {
russia: '+7 (926) 459-31-57',
armenia: '+374 94 86 18 16'

View File

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