1 Commits

Author SHA1 Message Date
sdarbinyan
6689acbe57 created auth system 2026-02-28 17:18:24 +04:00
45 changed files with 1461 additions and 2679 deletions

View File

@@ -1,168 +1,266 @@
# Backend API Changes Required # API Changes Required for Backend
## Cart Quantity Support ## Overview
### 1. Add Quantity to Cart Items Frontend has been updated with two new features:
1. **Region/Location system** — catalog filtering by region
2. **Auth/Login system** — Telegram-based authentication required before payment
**Current GET /cart Response:** Base URLs:
```json - Dexar: `https://api.dexarmarket.ru:445`
[ - Novo: `https://api.novo.market:444`
{
"itemID": 123,
"name": "Product Name",
"price": 100,
"currency": "RUB",
...other item fields
}
]
```
**NEW Required Response:**
```json
[
{
"itemID": 123,
"name": "Product Name",
"price": 100,
"currency": "RUB",
"quantity": 2, // <-- ADD THIS FIELD
...other item fields
}
]
```
### 2. POST /cart - Add Item to Cart
**Current Request:**
```json
{
"itemID": 123
}
```
**NEW Request (with optional quantity):**
```json
{
"itemID": 123,
"quantity": 1 // Optional, defaults to 1 if not provided
}
```
**Behavior:**
- If item already exists in cart, **increment** the quantity by the provided amount
- If item doesn't exist, add it with the specified quantity
### 3. PATCH /cart - Update Item Quantity (NEW ENDPOINT)
**Request:**
```json
{
"itemID": 123,
"quantity": 5 // New quantity value (not increment, but absolute value)
}
```
**Response:**
```json
{
"message": "Cart updated successfully"
}
```
**Behavior:**
- Set the quantity to the exact value provided
- If quantity is 0 or negative, remove the item from cart
### 4. Payment Endpoints - Include Quantity
**POST /payment/create**
Update the items array to include quantity:
**Current:**
```json
{
"amount": 1000,
"currency": "RUB",
"items": [
{
"itemID": 123,
"price": 500,
"name": "Product Name"
}
]
}
```
**NEW:**
```json
{
"amount": 1000,
"currency": "RUB",
"items": [
{
"itemID": 123,
"price": 500,
"name": "Product Name",
"quantity": 2 // <-- ADD THIS FIELD
}
]
}
```
### 5. Email Purchase Confirmation
**POST /purchase-email**
Update items to include quantity:
**NEW:**
```json
{
"email": "user@example.com",
"telegramUserId": "123456",
"items": [
{
"itemID": 123,
"name": "Product Name",
"price": 500,
"currency": "RUB",
"quantity": 2 // <-- ADD THIS FIELD
}
]
}
```
## Future: Filters & Sorting (To Be Discussed)
### GET /category/{categoryID}
Add query parameters for filtering and sorting:
**Proposed Query Parameters:**
- `sort`: Sort order (e.g., `price_asc`, `price_desc`, `rating_desc`, `name_asc`)
- `minPrice`: Minimum price filter
- `maxPrice`: Maximum price filter
- `minRating`: Minimum rating filter (1-5)
- `count`: Number of items per page (already exists)
- `skip`: Offset for pagination (already exists)
**Example:**
```
GET /category/5?sort=price_asc&minPrice=100&maxPrice=500&minRating=4&count=20&skip=0
```
**Response:** Same as current (array of items)
--- ---
## Summary ## 1. Region / Location Endpoints
**Required NOW:** ### 1.1 `GET /regions` — List available regions
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
**Future (After Discussion):** Returns the list of regions where the marketplace operates.
- Sorting and filtering query parameters for category items endpoint
**Response** `200 OK`
```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 (display) |
| `countryCode` | string | yes | ISO 3166-1 alpha-2 code |
| `timezone` | string | no | IANA timezone string |
> If this endpoint is unavailable, the frontend falls back to 6 hardcoded regions (Moscow, SPB, Yerevan, Minsk, Almaty, Tbilisi).
---
### 1.2 Region Query Parameter on Existing Endpoints
The following **existing** endpoints now accept an optional `?region=` query parameter:
| Endpoint | Example |
|---------------------------------|----------------------------------------------|
| `GET /category` | `GET /category?region=moscow` |
| `GET /category/:id` | `GET /category/5?count=50&skip=0&region=spb` |
| `GET /item/:id` | `GET /item/123?region=yerevan` |
| `GET /searchitems` | `GET /searchitems?search=phone&region=moscow` |
| `GET /randomitems` | `GET /randomitems?count=5&region=almaty` |
**Behavior:**
- If `region` param is **present** → return only items/categories available in that region
- If `region` param is **absent** → return all items globally (current behavior, no change)
- The `region` value matches the `id` field from the `/regions` response
---
## 2. Auth / Login Endpoints
Authentication is **Telegram-based** with **cookie sessions** (HttpOnly, Secure, SameSite=None).
All auth endpoints must support CORS with `credentials: true`.
### 2.1 `GET /auth/session` — Check current session
Called on every page load to check if the user has an active session.
**Request:**
- Cookies: session cookie (set by backend)
- CORS: `withCredentials: true`
**Response `200 OK`** (authenticated):
```json
{
"sessionId": "sess_abc123",
"telegramUserId": 123456789,
"username": "john_doe",
"displayName": "John Doe",
"active": true,
"expiresAt": "2026-03-01T12:00:00Z"
}
```
**Response `200 OK`** (expired session):
```json
{
"sessionId": "sess_abc123",
"telegramUserId": 123456789,
"username": "john_doe",
"displayName": "John Doe",
"active": false,
"expiresAt": "2026-02-27T12:00:00Z"
}
```
**Response `401 Unauthorized`** (no session / invalid cookie):
```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_name + last_name) |
| `active` | boolean | yes | Whether session is currently valid |
| `expiresAt` | string | yes | ISO 8601 expiration datetime |
---
### 2.2 `GET /auth/telegram/callback` — Telegram bot auth callback
This is the URL the Telegram bot redirects to after the user starts the bot.
**Flow:**
1. Frontend generates link: `https://t.me/{botUsername}?start=auth_{encodedCallbackUrl}`
2. User clicks → opens Telegram → starts the bot
3. Bot sends user data to this callback endpoint
4. Backend creates session, sets `Set-Cookie` header
5. Frontend polls `GET /auth/session` every 3 seconds to detect when session becomes active
**Request** (from Telegram bot / webhook):
```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:** Should 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 |
| `Domain` | API domain | |
> **Important:** Since the API domain differs from the frontend domain, `SameSite=None` + `Secure=true` is required for the cookie to be sent cross-origin.
---
### 2.3 `POST /auth/logout` — End session
**Request:**
- Cookies: session cookie
- CORS: `withCredentials: true`
- Body: `{}` (empty)
**Response `200 OK`:**
```json
{
"message": "Logged out"
}
```
Should clear/invalidate the session cookie.
---
## 3. CORS Configuration
For auth cookies to work cross-origin, the backend CORS config must include:
```
Access-Control-Allow-Origin: https://dexarmarket.ru (NOT *)
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
```
> `Access-Control-Allow-Origin` **cannot** be `*` when `Allow-Credentials: true`. Must be the exact frontend origin.
For Novo, also allow `https://novo.market`.
---
## 4. Session Refresh Behavior
The frontend automatically re-checks the session **60 seconds before `expiresAt`**. If the backend supports session extension (sliding expiration), it can re-set the cookie with a fresh `Max-Age` on every `GET /auth/session` call.
---
## 5. Auth Gate — Checkout Flow
The checkout button (`POST /cart` payment) now requires authentication:
- If the user is **not logged in** → frontend shows a Telegram login dialog instead of proceeding
- If the user **is logged in** → checkout proceeds normally
- The session cookie is sent automatically with the payment request
No backend changes needed for the payment endpoint itself — just ensure it reads the session cookie if needed for order association.
---
## Summary of New Endpoints
| Method | Path | Purpose | Auth Required |
|--------|----------------------------|-----------------------------|---------------|
| `GET` | `/regions` | List available regions | No |
| `GET` | `/auth/session` | Check current session | Cookie |
| `GET` | `/auth/telegram/callback` | Telegram bot auth callback | No (from bot) |
| `POST` | `/auth/logout` | End session | Cookie |
## Summary of Modified Endpoints
| Method | Path | Change |
|--------|-------------------|---------------------------------------|
| `GET` | `/category` | Added optional `?region=` param |
| `GET` | `/category/:id` | Added optional `?region=` param |
| `GET` | `/item/:id` | Added optional `?region=` param |
| `GET` | `/searchitems` | Added optional `?region=` param |
| `GET` | `/randomitems` | Added optional `?region=` param |
---
## Telegram Bot Setup
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`

View File

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

72
package-lock.json generated
View File

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

View File

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

View File

@@ -19,4 +19,5 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<app-footer></app-footer> <app-footer></app-footer>
<app-telegram-login />
} }

View File

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

View File

@@ -27,6 +27,7 @@
</nav> </nav>
<div class="novo-right"> <div class="novo-right">
<app-region-selector />
<app-language-selector /> <app-language-selector />
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()"> <a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()">
@@ -106,6 +107,11 @@
} }
</a> </a>
<!-- Region Selector (desktop only) -->
<div class="dexar-region-selector dexar-lang-desktop">
<app-region-selector />
</div>
<!-- Language Selector (desktop only) --> <!-- Language Selector (desktop only) -->
<div class="dexar-lang-selector dexar-lang-desktop"> <div class="dexar-lang-selector dexar-lang-desktop">
<app-language-selector /> <app-language-selector />
@@ -171,6 +177,11 @@
</svg> </svg>
</a> </a>
<!-- Region Selector in mobile menu -->
<div class="dexar-mobile-lang">
<app-region-selector />
</div>
<!-- Language Selector in mobile menu --> <!-- Language Selector in mobile menu -->
<div class="dexar-mobile-lang"> <div class="dexar-mobile-lang">
<app-language-selector /> <app-language-selector />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -148,7 +148,6 @@ export const ru: Translations = {
mediumStock: 'Заканчивается', mediumStock: 'Заканчивается',
addToCart: 'Добавить в корзину', addToCart: 'Добавить в корзину',
description: 'Описание', description: 'Описание',
specifications: 'Характеристики',
reviews: 'Отзывы', reviews: 'Отзывы',
yourReview: 'Ваш отзыв', yourReview: 'Ваш отзыв',
leaveReview: 'Оставить отзыв', leaveReview: 'Оставить отзыв',
@@ -186,4 +185,17 @@ export const ru: Translations = {
retry: 'Попробовать снова', retry: 'Попробовать снова',
loading: 'Загрузка...', 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; mediumStock: string;
addToCart: string; addToCart: string;
description: string; description: string;
specifications: string;
reviews: string; reviews: string;
yourReview: string; yourReview: string;
leaveReview: string; leaveReview: string;
@@ -184,4 +183,17 @@ export interface Translations {
retry: string; retry: string;
loading: string; loading: string;
}; };
location: {
allRegions: string;
chooseRegion: string;
detectAuto: string;
};
auth: {
loginRequired: string;
loginDescription: string;
checking: string;
loginWithTelegram: string;
orScanQr: string;
loginNote: string;
};
} }

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

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

View File

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

View File

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

View File

@@ -5,25 +5,6 @@ export interface Photo {
type?: string; type?: string;
} }
export interface DescriptionField {
key: string;
value: string;
}
export interface Comment {
id?: string;
text: string;
author?: string;
stars?: number;
createdAt?: string;
}
export interface ItemTranslation {
name?: string;
simpleDescription?: string;
description?: DescriptionField[];
}
export interface Review { export interface Review {
rating?: number; rating?: number;
content?: string; content?: string;
@@ -56,20 +37,6 @@ export interface Item {
callbacks: Review[] | null; callbacks: Review[] | null;
questions: Question[] | null; questions: Question[] | null;
partnerID?: string; 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 { export interface CartItem extends Item {

View File

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

View File

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

View File

@@ -2,13 +2,13 @@ import { Component, computed, ChangeDetectionStrategy, signal, OnDestroy, inject
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CartService, ApiService, LanguageService } from '../../services'; import { CartService, ApiService, LanguageService, AuthService } from '../../services';
import { Item, CartItem } from '../../models'; import { Item, CartItem } from '../../models';
import { interval, Subscription } from 'rxjs'; import { interval, Subscription } from 'rxjs';
import { switchMap, take } from 'rxjs/operators'; import { switchMap, take } from 'rxjs/operators';
import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component'; import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component';
import { environment } from '../../../environments/environment'; 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 { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service'; import { TranslateService } from '../../i18n/translate.service';
@@ -28,6 +28,7 @@ export class CartComponent implements OnDestroy {
isnovo = environment.theme === 'novo'; isnovo = environment.theme === 'novo';
private i18n = inject(TranslateService); private i18n = inject(TranslateService);
private authService = inject(AuthService);
// Swipe state // Swipe state
swipedItemId = signal<number | null>(null); swipedItemId = signal<number | null>(null);
@@ -128,13 +129,17 @@ export class CartComponent implements OnDestroy {
readonly getMainImage = getMainImage; readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId; readonly trackByItemId = trackByItemId;
readonly getDiscountedPrice = getDiscountedPrice; readonly getDiscountedPrice = getDiscountedPrice;
readonly getBadgeClass = getBadgeClass;
checkout(): void { checkout(): void {
if (!this.termsAccepted) { if (!this.termsAccepted) {
alert(this.i18n.t('cart.acceptTerms')); alert(this.i18n.t('cart.acceptTerms'));
return; return;
} }
// Auth gate: require Telegram login before payment
if (!this.authService.isAuthenticated()) {
this.authService.requestLogin();
return;
}
this.openPaymentPopup(); this.openPaymentPopup();
} }

View File

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

View File

@@ -4,7 +4,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService } from '../../services'; import { ApiService, CartService } from '../../services';
import { Item } from '../../models'; import { Item } from '../../models';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass } from '../../utils/item.utils'; import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
@@ -107,5 +107,4 @@ export class CategoryComponent implements OnInit, OnDestroy {
readonly getDiscountedPrice = getDiscountedPrice; readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage; readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId; readonly trackByItemId = trackByItemId;
readonly getBadgeClass = getBadgeClass;
} }

View File

@@ -55,23 +55,7 @@
</div> </div>
<div class="novo-info"> <div class="novo-info">
<h1 class="novo-title">{{ getItemName() }}</h1> <h1 class="novo-title">{{ item()!.name }}</h1>
@if (item()!.badges && item()!.badges!.length > 0) {
<div class="novo-badges">
@for (badge of item()!.badges!; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
@if (item()!.tags && item()!.tags!.length > 0) {
<div class="novo-tags">
@for (tag of item()!.tags!; track tag) {
<span class="item-tag">#{{ tag }}</span>
}
</div>
}
<div class="novo-rating"> <div class="novo-rating">
<span class="stars">{{ getRatingStars(item()!.rating) }}</span> <span class="stars">{{ getRatingStars(item()!.rating) }}</span>
@@ -93,13 +77,10 @@
<div class="novo-stock"> <div class="novo-stock">
<span class="stock-label">{{ 'itemDetail.stock' | translate }}</span> <span class="stock-label">{{ 'itemDetail.stock' | translate }}</span>
<div class="stock-indicator" [class]="getStockClass()"> <div class="stock-indicator" [class.high]="item()!.remainings === 'high'" [class.medium]="item()!.remainings === 'medium'" [class.low]="item()!.remainings === 'low'">
<span class="dot"></span> <span class="dot"></span>
{{ getStockLabel() }} {{ item()!.remainings === 'high' ? ('itemDetail.inStock' | translate) : item()!.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lowStock' | translate) }}
</div> </div>
@if (item()!.quantity != null) {
<span class="stock-qty">({{ item()!.quantity }} шт.)</span>
}
</div> </div>
<button class="novo-add-cart" (click)="addToCart()"> <button class="novo-add-cart" (click)="addToCart()">
@@ -112,26 +93,8 @@
</button> </button>
<div class="novo-description"> <div class="novo-description">
@if (getSimpleDescription()) {
<p class="novo-simple-desc">{{ getSimpleDescription() }}</p>
}
@if (hasDescriptionFields()) {
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
<table class="novo-specs-table">
<tbody>
@for (field of getTranslatedDescriptionFields(); track field.key) {
<tr>
<td class="spec-key">{{ field.key }}</td>
<td class="spec-value">{{ field.value }}</td>
</tr>
}
</tbody>
</table>
} @else {
<h3>{{ 'itemDetail.description' | translate }}</h3> <h3>{{ 'itemDetail.description' | translate }}</h3>
<div [innerHTML]="getSafeHtml(item()!.description)"></div> <div [innerHTML]="getSafeHtml(item()!.description)"></div>
}
</div> </div>
</div> </div>
</div> </div>
@@ -286,23 +249,7 @@
<!-- Item Info --> <!-- Item Info -->
<div class="dx-info"> <div class="dx-info">
<h1 class="dx-title">{{ getItemName() }}</h1> <h1 class="dx-title">{{ item()!.name }}</h1>
@if (item()!.badges && item()!.badges!.length > 0) {
<div class="dx-badges">
@for (badge of item()!.badges!; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
@if (item()!.tags && item()!.tags!.length > 0) {
<div class="dx-tags">
@for (tag of item()!.tags!; track tag) {
<span class="item-tag">#{{ tag }}</span>
}
</div>
}
<div class="dx-rating"> <div class="dx-rating">
<div class="dx-stars"> <div class="dx-stars">
@@ -330,13 +277,13 @@
<div class="dx-stock"> <div class="dx-stock">
<span class="dx-stock-label">{{ 'itemDetail.stock' | translate }}</span> <span class="dx-stock-label">{{ 'itemDetail.stock' | translate }}</span>
<span class="dx-stock-status" [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> <span class="dx-stock-dot"></span>
{{ getStockLabel() }} {{ item()!.remainings === 'high' ? ('itemDetail.inStock' | translate) : item()!.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lastItems' | translate) }}
</span> </span>
@if (item()!.quantity != null) {
<span class="dx-stock-qty">({{ item()!.quantity }} шт.)</span>
}
</div> </div>
<button class="dx-add-cart" (click)="addToCart()"> <button class="dx-add-cart" (click)="addToCart()">
@@ -349,26 +296,8 @@
</button> </button>
<div class="dx-description"> <div class="dx-description">
@if (getSimpleDescription()) {
<p class="dx-simple-desc">{{ getSimpleDescription() }}</p>
}
@if (hasDescriptionFields()) {
<h2>{{ 'itemDetail.specifications' | translate }}</h2>
<table class="dx-specs-table">
<tbody>
@for (field of getTranslatedDescriptionFields(); track field.key) {
<tr>
<td class="spec-key">{{ field.key }}</td>
<td class="spec-value">{{ field.value }}</td>
</tr>
}
</tbody>
</table>
} @else {
<h2>{{ 'itemDetail.description' | translate }}</h2> <h2>{{ 'itemDetail.description' | translate }}</h2>
<div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div> <div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div>
}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -642,70 +642,22 @@ $dx-card-bg: #f5f3f9;
} }
} }
// ========== DEXAR RESPONSIVE ========== // Responsive
// Large desktop — constrain gallery height
@media (min-width: 1201px) {
.dx-main-photo {
max-height: 560px;
}
}
// Tablet landscape / small desktop
@media (max-width: 1200px) {
.dx-item-content {
gap: 32px;
}
.dx-title {
font-size: 1.5rem;
}
}
// Tablet portrait
@media (max-width: 992px) { @media (max-width: 992px) {
.dx-item-content { .dx-item-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 32px; gap: 32px;
} }
.dx-gallery {
max-width: 600px;
margin: 0 auto;
} }
.dx-main-photo {
max-height: 480px;
aspect-ratio: auto;
}
.dx-add-cart {
max-width: 100%;
}
.dx-reviews-section,
.dx-qa-section {
h2 {
font-size: 1.3rem;
}
}
}
// Mobile
@media (max-width: 768px) { @media (max-width: 768px) {
.dx-item-container { .dx-item-container {
padding: 16px; padding: 16px;
} }
.dx-item-content { // On mobile: thumbnails go below main photo
gap: 24px;
margin-bottom: 32px;
}
// Thumbnails go below main photo
.dx-gallery { .dx-gallery {
flex-direction: column; flex-direction: column;
max-width: 100%;
} }
.dx-thumbnails { .dx-thumbnails {
@@ -714,16 +666,14 @@ $dx-card-bg: #f5f3f9;
max-height: none; max-height: none;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
order: 1; order: 1; // put below main photo
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar { display: none; } &::-webkit-scrollbar { display: none; }
} }
.dx-main-photo { .dx-main-photo {
order: 0; order: 0; // main photo first
max-height: 400px;
aspect-ratio: auto;
} }
.dx-thumb { .dx-thumb {
@@ -740,32 +690,8 @@ $dx-card-bg: #f5f3f9;
font-size: 1.8rem; font-size: 1.8rem;
} }
.dx-old-price {
font-size: 1rem;
}
.dx-add-cart { .dx-add-cart {
max-width: 100%; max-width: 100%;
padding: 14px 20px;
font-size: 1rem;
}
.dx-description {
h2 {
font-size: 1.15rem;
}
}
.dx-specs-table {
.spec-key {
white-space: normal;
width: auto;
}
td {
padding: 8px 10px;
font-size: 0.85rem;
}
} }
.dx-review-form { .dx-review-form {
@@ -781,153 +707,21 @@ $dx-card-bg: #f5f3f9;
width: 100%; width: 100%;
} }
} }
.dx-review-card {
padding: 16px;
} }
.dx-reviews-section,
.dx-qa-section {
margin-bottom: 32px;
h2 {
font-size: 1.25rem;
margin-bottom: 16px;
}
}
.dx-qa-card {
padding: 16px;
}
.dx-question,
.dx-answer {
font-size: 0.9rem;
}
}
// Small mobile
@media (max-width: 480px) { @media (max-width: 480px) {
.dx-item-container {
padding: 12px;
}
.dx-item-content {
gap: 20px;
margin-bottom: 24px;
}
.dx-main-photo {
max-height: 300px;
border-radius: 10px;
img, video {
padding: 8px;
}
}
.dx-thumb { .dx-thumb {
width: 52px; width: 56px;
height: 52px; height: 56px;
min-width: 52px; min-width: 56px;
} }
.dx-title { .dx-title {
font-size: 1.2rem; font-size: 1.25rem;
}
.dx-info {
gap: 16px;
} }
.dx-current-price { .dx-current-price {
font-size: 1.5rem; font-size: 1.6rem;
}
.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;
} }
} }
@@ -1505,20 +1299,12 @@ $dx-card-bg: #f5f3f9;
} }
} }
// ========== NOVO RESPONSIVE ==========
// Tablet portrait
@media (max-width: 968px) { @media (max-width: 968px) {
.novo-item-content { .novo-item-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 2rem; gap: 2rem;
} }
.novo-gallery {
max-width: 600px;
margin: 0 auto;
}
.novo-info .novo-title { .novo-info .novo-title {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -1527,10 +1313,6 @@ $dx-card-bg: #f5f3f9;
font-size: 2rem; font-size: 2rem;
} }
.novo-info .novo-add-cart {
max-width: 100%;
}
.novo-review-form { .novo-review-form {
padding: 1.5rem; padding: 1.5rem;
@@ -1545,302 +1327,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 { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services'; import { ApiService, CartService, TelegramService, SeoService } from '../../services';
import { Item, DescriptionField } from '../../models'; import { Item } from '../../models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { SecurityContext } from '@angular/core'; import { SecurityContext } from '@angular/core';
import { getDiscountedPrice, getAllImages, getStockStatus, getBadgeClass, getTranslatedField } from '../../utils/item.utils'; import { getDiscountedPrice } from '../../utils/item.utils';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service'; import { TranslateService } from '../../i18n/translate.service';
@@ -48,8 +48,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
private apiService: ApiService, private apiService: ApiService,
private cartService: CartService, private cartService: CartService,
private telegramService: TelegramService, private telegramService: TelegramService,
private sanitizer: DomSanitizer, private sanitizer: DomSanitizer
private languageService: LanguageService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -101,57 +100,6 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
return getDiscountedPrice(currentItem); return getDiscountedPrice(currentItem);
} }
// BackOffice integration helpers
getItemName(): string {
const currentItem = this.item();
if (!currentItem) return '';
const lang = this.languageService.currentLanguage();
return getTranslatedField(currentItem, 'name', lang);
}
getSimpleDescription(): string {
const currentItem = this.item();
if (!currentItem) return '';
const lang = this.languageService.currentLanguage();
return getTranslatedField(currentItem, 'simpleDescription', lang);
}
hasDescriptionFields(): boolean {
const currentItem = this.item();
return !!(currentItem?.descriptionFields && currentItem.descriptionFields.length > 0);
}
getTranslatedDescriptionFields(): DescriptionField[] {
const currentItem = this.item();
if (!currentItem) return [];
const lang = this.languageService.currentLanguage();
const translation = currentItem.translations?.[lang];
if (translation?.description && translation.description.length > 0) {
return translation.description;
}
return currentItem.descriptionFields || [];
}
getStockClass(): string {
const currentItem = this.item();
if (!currentItem) return 'high';
return getStockStatus(currentItem);
}
getStockLabel(): string {
const status = this.getStockClass();
switch (status) {
case 'high': return 'В наличии';
case 'medium': return 'Заканчивается';
case 'low': return 'Последние штуки';
case 'out': return 'Нет в наличии';
default: return 'В наличии';
}
}
readonly getBadgeClass = getBadgeClass;
getSafeHtml(html: string): SafeHtml { getSafeHtml(html: string): SafeHtml {
return this.sanitizer.sanitize(SecurityContext.HTML, html) || ''; return this.sanitizer.sanitize(SecurityContext.HTML, html) || '';
} }

View File

@@ -63,22 +63,11 @@
@if (item.discount > 0) { @if (item.discount > 0) {
<div class="discount-badge">-{{ item.discount }}%</div> <div class="discount-badge">-{{ item.discount }}%</div>
} }
@if (item.badges && item.badges.length > 0) {
<div class="item-badges-overlay">
@for (badge of item.badges; track badge) {
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
}
</div>
}
</div> </div>
<div class="item-details"> <div class="item-details">
<h3 class="item-name">{{ item.name }}</h3> <h3 class="item-name">{{ item.name }}</h3>
@if (item.simpleDescription) {
<p class="item-simple-desc">{{ item.simpleDescription }}</p>
}
<div class="item-rating"> <div class="item-rating">
<span class="rating-stars">⭐ {{ item.rating }}</span> <span class="rating-stars">⭐ {{ item.rating }}</span>
<span class="rating-count">({{ item.callbacks?.length || 0 }})</span> <span class="rating-count">({{ item.callbacks?.length || 0 }})</span>

View File

@@ -6,7 +6,7 @@ import { ApiService, CartService } from '../../services';
import { Item } from '../../models'; import { Item } from '../../models';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass } from '../../utils/item.utils'; import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service'; import { TranslateService } from '../../i18n/translate.service';
@@ -136,5 +136,4 @@ export class SearchComponent implements OnDestroy {
readonly getDiscountedPrice = getDiscountedPrice; readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage; readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId; readonly trackByItemId = trackByItemId;
readonly getBadgeClass = getBadgeClass;
} }

View File

@@ -1,160 +1,71 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { Category, Item, Subcategory } from '../models'; import { Category, Item } from '../models';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { LocationService } from './location.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class ApiService { export class ApiService {
private readonly baseUrl = environment.apiUrl; private readonly baseUrl = environment.apiUrl;
private locationService = inject(LocationService);
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
/** private normalizeItem(item: Item): Item {
* Normalize an item from the API response — supports both return {
* legacy marketplace format and the new backOffice API format. ...item,
*/ remainings: item.remainings || 'high'
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[] private normalizeItems(items: Item[] | null | undefined): Item[] {
if (raw.imgs && (!raw.photos || raw.photos.length === 0)) {
item.photos = raw.imgs.map((url: string) => ({ url }));
}
item.imgs = raw.imgs || raw.photos?.map((p: any) => p.url) || [];
// Map backOffice description (key-value array) → legacy description string
if (Array.isArray(raw.description)) {
item.descriptionFields = raw.description;
item.description = raw.description.map((d: any) => `${d.key}: ${d.value}`).join('\n');
} else {
item.description = raw.description || raw.simpleDescription || '';
}
// Map backOffice comments → legacy callbacks
if (raw.comments && (!raw.callbacks || raw.callbacks.length === 0)) {
item.callbacks = raw.comments.map((c: any) => ({
rating: c.stars,
content: c.text,
userID: c.author,
timestamp: c.createdAt,
}));
}
item.comments = raw.comments || raw.callbacks?.map((c: any) => ({
id: c.userID,
text: c.content,
author: c.userID,
stars: c.rating,
createdAt: c.timestamp,
})) || [];
// Compute average rating from comments if not present
if (raw.rating == null && item.comments && item.comments.length > 0) {
const rated = item.comments.filter(c => c.stars != null);
item.rating = rated.length > 0
? rated.reduce((sum, c) => sum + (c.stars || 0), 0) / rated.length
: 0;
}
item.rating = item.rating || 0;
// Defaults
item.discount = item.discount || 0;
item.remainings = item.remainings || (raw.quantity != null
? (raw.quantity <= 0 ? 'out' : raw.quantity <= 5 ? 'low' : raw.quantity <= 20 ? 'medium' : 'high')
: 'high');
item.currency = item.currency || 'RUB';
// Preserve new backOffice fields
item.badges = raw.badges || [];
item.tags = raw.tags || [];
item.simpleDescription = raw.simpleDescription || '';
item.translations = raw.translations || {};
item.visible = raw.visible ?? true;
item.priority = raw.priority ?? 0;
return item;
}
private normalizeItems(items: any[] | null | undefined): Item[] {
if (!items || !Array.isArray(items)) { if (!items || !Array.isArray(items)) {
return []; return [];
} }
return items.map(item => this.normalizeItem(item)); return items.map(item => this.normalizeItem(item));
} }
/** /** Append region query param if a region is selected */
* Normalize a category from the API response — supports both private withRegion(params: HttpParams = new HttpParams()): HttpParams {
* the flat legacy format and nested backOffice format. const regionId = this.locationService.regionId();
*/ return regionId ? params.set('region', regionId) : params;
private normalizeCategory(raw: any): Category {
const cat: Category = { ...raw };
if (raw.id != null && raw.categoryID == null) {
cat.id = String(raw.id);
cat.categoryID = typeof raw.id === 'number' ? raw.id : 0;
} }
// Map backOffice img → legacy icon
if (raw.img && !raw.icon) {
cat.icon = raw.img;
}
cat.img = raw.img || raw.icon;
cat.parentID = raw.parentID ?? 0;
cat.visible = raw.visible ?? true;
cat.priority = raw.priority ?? 0;
if (raw.subcategories && Array.isArray(raw.subcategories)) {
cat.subcategories = raw.subcategories;
}
return cat;
}
private normalizeCategories(cats: any[] | null | undefined): Category[] {
if (!cats || !Array.isArray(cats)) return [];
return cats.map(c => this.normalizeCategory(c));
}
// ─── Core Marketplace Endpoints ───────────────────────────
ping(): Observable<{ message: string }> { ping(): Observable<{ message: string }> {
return this.http.get<{ message: string }>(`${this.baseUrl}/ping`); return this.http.get<{ message: string }>(`${this.baseUrl}/ping`);
} }
getCategories(): Observable<Category[]> { getCategories(): Observable<Category[]> {
return this.http.get<any[]>(`${this.baseUrl}/category`) return this.http.get<Category[]>(`${this.baseUrl}/category`, { params: this.withRegion() });
.pipe(map(cats => this.normalizeCategories(cats)));
} }
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> { getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
const params = new HttpParams() const params = this.withRegion(
new HttpParams()
.set('count', count.toString()) .set('count', count.toString())
.set('skip', skip.toString()); .set('skip', skip.toString())
return this.http.get<any[]>(`${this.baseUrl}/category/${categoryID}`, { params }) );
return this.http.get<Item[]>(`${this.baseUrl}/category/${categoryID}`, { params })
.pipe(map(items => this.normalizeItems(items))); .pipe(map(items => this.normalizeItems(items)));
} }
getItem(itemID: number): Observable<Item> { getItem(itemID: number): Observable<Item> {
return this.http.get<any>(`${this.baseUrl}/item/${itemID}`) return this.http.get<Item>(`${this.baseUrl}/item/${itemID}`, { params: this.withRegion() })
.pipe(map(item => this.normalizeItem(item))); .pipe(map(item => this.normalizeItem(item)));
} }
searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> { searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> {
const params = new HttpParams() const params = this.withRegion(
new HttpParams()
.set('search', search) .set('search', search)
.set('count', count.toString()) .set('count', count.toString())
.set('skip', skip.toString()); .set('skip', skip.toString())
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params }) );
return this.http.get<{ items: Item[], total: number, count: number, skip: number }>(`${this.baseUrl}/searchitems`, { params })
.pipe( .pipe(
map(response => ({ map(response => ({
items: this.normalizeItems(response?.items || []), items: this.normalizeItems(response?.items || []),
@@ -176,7 +87,7 @@ export class ApiService {
} }
getCart(): Observable<Item[]> { 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))); .pipe(map(items => this.normalizeItems(items)));
} }
@@ -258,11 +169,11 @@ export class ApiService {
} }
getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> { getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> {
let params = new HttpParams().set('count', count.toString()); let params = this.withRegion(new HttpParams().set('count', count.toString()));
if (categoryID) { if (categoryID) {
params = params.set('category', categoryID.toString()); params = params.set('category', categoryID.toString());
} }
return this.http.get<any[]>(`${this.baseUrl}/randomitems`, { params }) return this.http.get<Item[]>(`${this.baseUrl}/randomitems`, { params })
.pipe(map(items => this.normalizeItems(items))); .pipe(map(items => this.normalizeItems(items)));
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,78 +1,13 @@
import { Item } from '../models'; import { Item } from '../models';
export function getDiscountedPrice(item: Item): number { export function getDiscountedPrice(item: Item): number {
return item.price * (1 - (item.discount || 0) / 100); return item.price * (1 - item.discount / 100);
} }
export function getMainImage(item: Item): string { export function getMainImage(item: Item): string {
// Support both backOffice format (imgs: string[]) and legacy (photos: Photo[])
if (item.imgs && item.imgs.length > 0) {
return item.imgs[0];
}
return item.photos?.[0]?.url || '/assets/images/placeholder.svg'; return item.photos?.[0]?.url || '/assets/images/placeholder.svg';
} }
export function getAllImages(item: Item): string[] { export function trackByItemId(index: number, item: Item): number {
if (item.imgs && item.imgs.length > 0) { return item.itemID;
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 '';
} }

View File

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

View File

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

View File

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

View File

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

View File

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