32 Commits

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

2
.gitignore vendored
View File

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

View File

@@ -154,7 +154,7 @@
}, },
"serve": { "serve": {
"options": { "options": {
"allowedHosts": ["novo.market", "dexarmarket.ru", "localhost"], "allowedHosts": ["novo.market", "dexarmarket.ru", "dexar.market","localhost"],
"proxyConfig": "proxy.conf.json" "proxyConfig": "proxy.conf.json"
}, },
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
@@ -176,28 +176,9 @@
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular/build:extract-i18n" "builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
}
} }
} }
} }
} }
} }

View File

@@ -1,11 +1,748 @@
bro we need to do changes, that client required General Information
1. we need to add location logic Information exchange with the SBP server is realized via RESTful API. All requests to the server must be executed via HTTPS using GET||POST||PUT||DELETE requests to the given ROOT address. Body of requests must be in JSON format. All not public requests must be signed by the client and the public key must be sent to the server for client identification and sign checking.
1.1 the catalogs will come or for global or for exact region Header:
1.2 need to add a place where the user can choose his region like city if choosed moscow the country is set russian “Authorization”: {JSON WITH KEY AND PARTNERID}
1.3 can we try to understand what country is user logged or whach city by global ip and set it? “X-Region” : Moscow | Yerevan | ST. Petersburg
2. we need to add somekind of user login logic “X-Language” : RU | AM | EN
2.1 user can add to cart, look the items and etc without logged in, but when he is going to buy/pay -> “WebSessionID” : f02fe5d6-c6ae-4b2e-9b4d-687534e11b01
at first he have to login with telegram, i will send you the bots adress. “Currency” :RUB | AMD | USD
2.1.1 if is not logged -> will see the QR or link for logging via telegram Root:
2.1.2 if logged we need to ping server to check if he is active user. the expiration date (like day or 5 days) we will get from bakcend with session id API.dexarmarket.ru
2.2 and when user is logged, that time he can do a payment
General Information
Check if server is available
Get Marketplaces
Set Marketplaces
Get Item
Delete Item
New Item
New Callback
New Question
Get random Items
Get items in category
Get searched items
Get Categories
Delete Category
New Category
Create new websession
Check websession status
Delete websession status
Add to cart
Create New QR code for cart checkout
Check QR code
item structure
category structure
Check if server is available
Client needs to periodically check if the server is available by sending “ping” to the client. On error corresponding message must be shown.
Protocol: https
Type: GET
Path: /ping
Request Parameters:
{
}
Response (Error):
{
"message": "pong",
"status": "Wrong Header"
}
Response (OK):
{
"message": "pong",
"status": "Correct Header"
}
________________
Get Marketplaces
Get Available Marketplaces
Protocol: https
Type: GET
Path: /marketplaces
Request Parameters:
{
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
[{“brand” : “dexar”,
“api”:”dexar.market”,
“bot”:”dexarmarket_bot”,
“languagies”:[“”am,”ru”,”en”],
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”, ”Yerevan - Armenia”]
“currency”:[“RUB, ”AMD”, ”USD”]
“icon”:”./dexar.market.png”},
{“brand” : “store”,
“api”:”dexarmarket.store”,
“bot”:”dexarstore_bot”,
“languagies”:[“”am,”ru”,”en”],
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
“currency”:[“”RUB,”AMD”,”USD”]
“icon”:”./dexarmarket.store.png”},
{“brand” : “Novo”,
“api”:”novo.market”,
“bot”:”novomarket_bot”,
“languagies”:[“”am,”ru”,”en”],
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”,”Yerevan - Armenia”]
“currency”:[“”RUB,”AMD”,”USD”]
“icon”:”./novo.market.png”}]
}
________________
Set Marketplaces
Get Available Marketplaces
Protocol: https
Type: PUT
Path: /marketplaces
Request Parameters:
{
[{“brand” : “dexar”,
“api”:”dexar.market”,
“languagies”:[“”am,”ru”,”en”],
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
“currency”:[“”RUB,”AMD”,”USD”]
“icon”:”./dexar.market.png”},
{“brand” : “store”,
“api”:”dexarmarket.store”,
“languagies”:[“”am,”ru”,”en”],
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
“currency”:[“”RUB,”AMD”,”USD”]
“icon”:”./dexarmarket.store.png”},
{“brand” : “Novo”,
“api”:”novo.market”,
“languagies”:[“”am,”ru”,”en”],
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”,”Yerevan - Armenia”]
“currency”:[“”RUB,”AMD”,”USD”]
“icon”:”./novo.market.png”}]
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
“status”:”Marketplace updated”
}
________________
Get Item
Get Item by ID
Protocol: https
Type: GET
Path: /items/:itemID
Request Parameters:
{
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
“itemID”:...
}
________________
Delete Item
Delete the item
Protocol: https
Type: Delete
Path: /items/:itemID
Request Parameters:
{
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
“status”:”Item was deleted”
}
________________
New Item
Create new Item
Protocol: https
Type: POST
Path: /items/:itemID
Request Parameters:
{
“itemID”:...
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
“itemID”:...
}
________________
Update Item
Update the item
Protocol: https
Type: PUT
Path: /items/:itemID
Request Parameters:
{
“itemID”:...
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
“status”:”Item updated”
}
________________
New Callback
Update the item
Protocol: https
Type: POST
Path: /items/:itemID/callback
Request Parameters:
{
"rating": 5,
"comment": "Отличный товар!",
"sessionID": “ f02fe5d6-c6ae-4b2e-9b4d-687534e11b01”
"timestamp": "2026-02-28T12:00:00Z"
}
Response !=200(Error):
{
"error": "wrong item"
}
Response =200(OK):
{
“status”:”Callback added”
}
________________
New Question
Update the item
Protocol: https
Type: POST
Path: /items/:itemID/questiion
Request Parameters:
{
"question": "some question!",
"sessionID": “ f02fe5d6-c6ae-4b2e-9b4d-687534e11b01”
"timestamp": "2026-02-28T12:00:00Z"
}
Response !=200(Error):
{
"error": "wrong item"
}
Response =200(OK):
{
“status”:”Questiion added”
}
________________
Get random Items
Get given number of items from random categorues
Protocol: https
Type: GET
Path: /items/randomitems?count=15 // 20 is the default
Request Parameters:
{
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
[“itemID”:...]
}
________________
Get items in category
Get all items in category and in all subcategories inside the category
Protocol: https
Type: GET
Path: /category/:categoryID?count=30, skip=60 // default skip=0, default count=20
Request Parameters:
{
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
[“itemID”:...]
}
________________
Get searched items
Get all items in category and in all subcategories inside the category
Protocol: https
Type: GET
Path: /searchitems
Parameters:
{
search (string) — query text
categoryIDs (string) — e.g., 1,2,5 (includes all subcategories)
minPrice / maxPrice (float) — price range
tag (string) — e.g., sale
sort (string) — relevance (default), price_asc, price_desc, popular, rating
skip / count — default 0 / 20
}
Examples:
* ?search=iphone&sort=popular
* ?categoryIDs=1,5&minPrice=100&maxPrice=500
* ?tag=new&sort=price_asc&count=10
Request Parameters:
{
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
"total": 12,
"skip": 0,
"count": 12,
"isGlobal": false,
"items": [
{ "itemID": 101, "name": "..." }
]
}
________________
Get Categories
Get all available categories
Protocol: https
Type: GET
Path: /category
Request Parameters:
{
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
“categoryID”:...
}
________________
Delete Category
Delete EMPTY category, no items and no subcategories must present
Protocol: https
Type: Delete
Path: /category/:categoryID
Request Parameters:
{
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
“status”:”Category was deleted”
}
________________
New Category
Create new category
Protocol: https
Type: POST
Path: /category/
Request Parameters:
{
“CategoryID”:...
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
“CategoryID”:...
}
________________
Update Category
Update existing category
Protocol: https
Type: PUT
Path: /category/:categoryID
Request Parameters:
{
“itemID”:...
}
Response !=200(Error):
{
"error": "wrong header"
}
Response =200(OK):
{
“status”:”Category was updated”
}
________________
Create new websession
Creates a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
Protocol: https
Type POST
Path /websession
Request Parameters:
{
}
Response (OK):
{
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
"userId" : "",
"expires" : "sessionId",
"userSessionId": "",
"status": false
}
________________
Check websession status
Check if the user is already logged in. a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
Protocol: https
Type GET
Path /websession/:webSessionID
Request Parameters:
{
}
Response (OK):
{
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
"expires" : "sessionId",
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
"x-Region" : "Moscow",
"x-Language" : "RU",
"currency" : "RUB",
"Status": true,
"cart": [
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
]
}
________________
Delete websession status
Delete the session to log out from the system.
Protocol: https
Type DELETE
Path /websession/:webSessionID
Request Parameters:
{
}
Response (OK):
{
“status”:”User logged out”
}
________________
Add to cart
Add a all item to users (session) cart
Protocol: https
Type Post
Path /websession/:webSessionID
Request Parameters:
{
[
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
]
}
Response (OK):
{
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
"expires" : "sessionId",
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
"Status": true,
"cart": [
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
]
}
________________
Create New QR code for cart checkout
Create New QR for payment via SBP
Protocol: https
Type POST
Path /websession/:webSessionID/qr
Request Parameters:
{
}
Response !=200(Error):
{
"error": "wrong key"
}
Response =200(OK):
{
"qrId": "BD10002CI1V3JP1T8QR8TIQ8K35RBVQB",
"qrStatus": "NEW",
"qrExpirationDate": "2025-11-20T10:10:44Z",
"Payload": "https://qr.nspk.ru/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB?type=02&bank=100000000007&sum=1000&cur=RUB&crc=8ACC",
"qrUrl": "https://e-commerce.raiffeisen.ru/api/sbp/v1/qr/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB/image"
}
________________
Check QR code
Check QR status
Protocol: https
Type GET
Path /websession/:webSessionID/:qrID
Request Parameters:
{
}
Response !=200(Error):
{
"error": "Error from the bank "
}
Response =200(OK):
{
"additionalInfo": "",
"paymentPurpose": "",
"amount": 10,
"code": "SUCCESS",
"createDate": "2025-11-20T13:17:20.453884+03:00",
"currency": "RUB",
"order": "102_540",
"paymentStatus": "NO_INFO", //check for SUCCESS
"qrId": "BD1000263VS7G81D8JCP5FHFTFEH38MT",
"transactionDate": "",
"transactionId": 0,
"qrExpirationDate": "2025-11-20T13:32:20+03:00"
}
## 8. Авторизация (вход через Telegram)
Авторизация **через Telegram** с **cookie-сессиями** (HttpOnly, Secure, SameSite=None).
Все auth-эндпоинты должны поддерживать CORS с `credentials: true`.
### Процесс авторизации
```
1. Пользователь нажимает «Оформить заказ» → не авторизован → показывается диалог входа
2. Нажимает «Войти через Telegram» → открывается https://t.me/{bot}?start=auth_{callback}
3. Пользователь запускает бота в Telegram
4. Бот отправляет данные пользователя → бэкенд /auth/telegram/callback
5. Бэкенд создаёт сессию → устанавливает Set-Cookie
6. Фронтенд опрашивает GET /auth/session каждые 3 секунды
7. Сессия обнаружена → диалог закрывается → оформление заказа продолжается
```
---
### `GET /auth/session` — Проверить текущую сессию
**Запрос:** Только cookie (сессионная cookie, установленная бэкендом).
**Ответ `200`** (авторизован):
```json
{
"sessionId": "sess_abc123",
"telegramUserId": 123456789,
"username": "john_doe",
"displayName": "John Doe",
"active": true,
"expiresAt": "2026-03-01T12:00:00Z"
}
```
**Ответ `200`** (сессия истекла):
```json
{
"sessionId": "sess_abc123",
"telegramUserId": 123456789,
"username": "john_doe",
"displayName": "John Doe",
"active": false,
"expiresAt": "2026-02-27T12:00:00Z"
}
```
**Ответ `401`** (нет сессии):
```json
{ "error": "No active session" }
```
**Объект AuthSession:**
| Поле | Тип | Обязат. | Описание |
|------------------|---------|---------|-------------------------------------------|
| `sessionId` | string | да | Уникальный ID сессии |
| `telegramUserId` | number | да | ID пользователя в Telegram |
| `username` | string? | нет | @username в Telegram (может быть null) |
| `displayName` | string | да | Отображаемое имя (имя + фамилия) |
| `active` | boolean | да | Действительна ли сессия |
| `expiresAt` | string | да | Дата истечения в формате ISO 8601 |
---
### `GET /auth/telegram/callback` — Callback авторизации Telegram-бота
Вызывается Telegram-ботом после авторизации пользователя.
**Тело запроса (от бота):**
```json
{
"id": 123456789,
"first_name": "John",
"last_name": "Doe",
"username": "john_doe",
"photo_url": "https://t.me/i/userpic/...",
"auth_date": 1709100000,
"hash": "abc123def456..."
}
```
Бот должен:
1. Слушать команду `/start auth_{callbackUrl}`
2. Извлечь callback URL
3. Отправить данные пользователя (`id`, `first_name`, `username` и т.д.) на этот callback URL
4. Callback URL: `{apiUrl}/auth/telegram/callback`
---
## Полный справочник эндпоинтов
### Новые эндпоинты
| Метод | Путь | Описание | Авторизация |
|--------|---------------------------|---------------------------------|-------------|
| `GET` | `/regions` | Список доступных регионов | Нет |
| `GET` | `/auth/session` | Проверка текущей сессии | Cookie |
| `GET` | `/auth/telegram/callback` | Callback авторизации через бота | Нет (бот) |
| `POST` | `/auth/logout` | Завершение сессии | Cookie |
________________
item structure
CategoryID uint64 `json:"categoryID" binding:"required"`
ItemID uint64 `json:"itemID" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Discount float32 `json:"discount" `
Rating float32 `json:"rating" binding:"required"`
Visible bool `json:"rating"`
Priority uint64 `json:"priority"`
Tags []string `json:"tags"`
Badges []string `json:"badges"`
Details []itemdetail `json:"itemdetails"`
Colour string `json:"colour" binding:"required"`
Size string `json:"size" binding:"required"`
Price float32 `json:"price" binding:"required"`
Currency string `json:"currency" binding:"required"`
Remaining uint64 `json:"remaining" binding:"required"`
Names []itemname `json:"names"`
Language string `json:"language"`
Value string `json:"value"`
Descriptions []itemdescription `json:"descriptions" `
Language string `json:"language"`
Value string `json:"value"`
Attributes []attribute `json:"attributes" binding:"required"`
Key string `json:"key"`
Value string `json:"value"`
Photos []photo `json:"photos"`
Type string `json:"type" binding:"required"` //video || photo
URL string `json:"url" binding:"required"`
Questions []question `json:"questions"`
Question string `json:"question" `
Answer string `json:"answer" `
Like uint64 `json:"like" `
Dislike uint64 `json:"dislike" `
Visits uint64 `json:"visits"`
Callbacks []callback `json:"callbacks" binding:"required"`
Rating float32 `json:"rating,omitempty"`
Content string `json:"content,omitempty"`
Userid string `json:"userID"`
Answer string `json:"answer"`
Timestamp string `json:"timestamp"`
PartnerID []string `json:"partnerID" binding:"required"`
category structure
CategoryID uint64 `json:"categoryID" binding:"required"`
ParentID uint64 `json:"parentID" binding:"required"`
Name string `json:"name" binding:"required"`
Visible bool `json:"visible" `
Priority uint64 `json:"priority" `
Icon string `json:"icon"`
WideIcon string `json:"wideicon"`
ItemsCount uint64
CategoriesCount uint64
Names []itemname `json:"names"`
Language string `json:"language"`
Value string `json:"value"`

View File

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

View File

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

1
package-lock.json generated
View File

@@ -9580,3 +9580,4 @@
} }
} }
} }

View File

@@ -47,3 +47,4 @@
"typescript": "~5.9.3" "typescript": "~5.9.3"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

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

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
import { provideRouter } from '@angular/router';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])]
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});

View File

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

View File

@@ -30,8 +30,8 @@
<app-region-selector /> <app-region-selector />
<app-language-selector /> <app-language-selector />
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()"> <a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()" [attr.aria-label]="'header.cart' | translate">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="9" cy="21" r="1"></circle> <circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle> <circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path> <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
@@ -41,7 +41,7 @@
} }
</a> </a>
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen"> <button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
@@ -118,7 +118,7 @@
</div> </div>
<!-- Mobile Menu Toggle --> <!-- Mobile Menu Toggle -->
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen"> <button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
// Payment polling
export const PAYMENT_POLL_INTERVAL_MS = 5000;
export const PAYMENT_MAX_CHECKS = 36;
export const PAYMENT_TIMEOUT_CLOSE_MS = 3000;
export const PAYMENT_ERROR_CLOSE_MS = 4000;
export const LINK_COPIED_DURATION_MS = 2000;
// Infinite scroll
export const SCROLL_THRESHOLD_PX = 1200;
export const SCROLL_DEBOUNCE_MS = 100;
export const ITEMS_PER_PAGE = 50;
// Search
export const SEARCH_DEBOUNCE_MS = 300;
export const SEARCH_MIN_LENGTH = 3;
// Cache
export const CACHE_DURATION_MS = 5 * 60 * 1000;
export const CATEGORY_CACHE_DURATION_MS = 2 * 60 * 1000;

View File

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

View File

@@ -735,34 +735,28 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
return respond([]); return respond([]);
} }
// ── POST /cart (add to cart / create payment) // ── POST /websession/:id (add to cart)
if (url.endsWith('/cart') && req.method === 'POST') { if (url.match(/\/websession\/[^/]+$/) && req.method === 'POST') {
const body = req.body as any; return respond({
if (body?.amount) { sessionId: 'mock-session',
// Payment mock Status: true,
return respond({ cart: req.body
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 // ── POST /websession/:id/qr (create payment QR)
if (url.endsWith('/cart') && req.method === 'PATCH') { if (url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'POST') {
return respond({ message: 'Updated (mock)' }); return respond({
qrId: 'mock-qr-' + Date.now(),
qrStatus: 'NEW',
qrExpirationDate: new Date(Date.now() + 180000).toISOString(),
Payload: 'https://example.com/pay/mock',
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment'
}, 300);
} }
// ── DELETE /cart // ── POST /items/:id/callback (review)
if (url.endsWith('/cart') && req.method === 'DELETE') { if (url.match(/\/items\/\d+\/callback$/) && req.method === 'POST') {
return respond({ message: 'Removed (mock)' });
}
// ── POST /comment
if (url.endsWith('/comment') && req.method === 'POST') {
return respond({ message: 'Review submitted (mock)' }, 200); return respond({ message: 'Review submitted (mock)' }, 200);
} }
@@ -771,8 +765,8 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
return respond({ message: 'Email sent (mock)' }, 200); return respond({ message: 'Email sent (mock)' }, 200);
} }
// ── GET /qr/payment/:id (always return success for testing) // ── GET /websession/:id/:qrId (check QR payment status)
if (url.includes('/qr/payment/') && req.method === 'GET') { if (url.match(/\/websession\/[^/]+\/[^/]+$/) && !url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'GET') {
return respond({ return respond({
paymentStatus: 'SUCCESS', paymentStatus: 'SUCCESS',
code: 'SUCCESS', code: 'SUCCESS',
@@ -785,8 +779,7 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
paymentPurpose: '', paymentPurpose: '',
createDate: new Date().toISOString(), createDate: new Date().toISOString(),
order: 'mock-order', order: 'mock-order',
qrExpirationDate: new Date().toISOString(), qrExpirationDate: new Date().toISOString()
phoneNumber: ''
}, 500); }, 500);
} }

View File

@@ -1,3 +1,5 @@
import { ItemName } from './item.model';
export interface Category { export interface Category {
categoryID: number; categoryID: number;
name: string; name: string;
@@ -5,7 +7,10 @@ export interface Category {
icon?: string; icon?: string;
wideBanner?: string; wideBanner?: string;
itemCount?: number; itemCount?: number;
categoriesCount?: number;
priority?: number; priority?: number;
names?: ItemName[];
translations?: Record<string, CategoryTranslation>;
// BackOffice API fields // BackOffice API fields
id?: string; id?: string;

View File

@@ -40,6 +40,8 @@ export interface Question {
answer: string; answer: string;
upvotes: number; upvotes: number;
downvotes: number; downvotes: number;
like?: number;
dislike?: number;
} }
/** Localized name entry from backend */ /** Localized name entry from backend */
@@ -60,6 +62,16 @@ export interface ItemAttribute {
value: string; value: string;
} }
/** Item variant detail (price, size, colour per variant) */
export interface ItemDetail {
color?: string;
colour?: string;
size?: string;
price: number;
currency: string;
remaining: number;
}
export interface Item { export interface Item {
categoryID: number; categoryID: number;
itemID: number; itemID: number;
@@ -95,6 +107,8 @@ export interface Item {
subcategoryId?: string; subcategoryId?: string;
translations?: Record<string, ItemTranslation>; translations?: Record<string, ItemTranslation>;
comments?: Comment[]; comments?: Comment[];
visits?: number;
itemDetails?: ItemDetail[];
} }
export interface CartItem extends Item { export interface CartItem extends Item {

View File

@@ -46,12 +46,15 @@
<p class="item-description">{{ itemDesc(item) || '' }}...</p> <p class="item-description">{{ itemDesc(item) || '' }}...</p>
@if (item.colour || item.size) { @if (item.colour || (item.size && item.size.toLowerCase() !== 'default')) {
<div class="cart-item-variants"> <div class="cart-item-variants">
@if (item.colour) { @if (item.colour) {
<span class="cart-variant">{{ 'itemDetail.colour' | translate }}: {{ item.colour }}</span> <span class="cart-variant cart-variant-colour">
{{ 'itemDetail.colour' | translate }}:
<span class="cart-colour-swatch" [style.background-color]="item.colour" [title]="item.colour"></span>
</span>
} }
@if (item.size) { @if (item.size && item.size.toLowerCase() !== 'default') {
<span class="cart-variant">{{ 'itemDetail.size' | translate }}: {{ item.size }}</span> <span class="cart-variant">{{ 'itemDetail.size' | translate }}: {{ item.size }}</span>
} }
</div> </div>

View File

@@ -368,6 +368,7 @@
display: flex; display: flex;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
margin-top: 4px; margin-top: 4px;
.cart-variant { .cart-variant {
@@ -377,6 +378,18 @@
padding: 3px 10px; padding: 3px 10px;
border-radius: 6px; border-radius: 6px;
font-weight: 500; font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
}
.cart-colour-swatch {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
vertical-align: middle;
} }
} }
@@ -484,6 +497,7 @@
display: flex; display: flex;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
margin-top: 4px; margin-top: 4px;
.cart-variant { .cart-variant {
@@ -493,6 +507,18 @@
padding: 3px 10px; padding: 3px 10px;
border-radius: 6px; border-radius: 6px;
font-weight: 500; font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
}
.cart-colour-swatch {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
vertical-align: middle;
} }
} }

View File

@@ -13,6 +13,7 @@ import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTran
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service'; import { TranslateService } from '../../i18n/translate.service';
import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS, PAYMENT_ERROR_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants';
@Component({ @Component({
selector: 'app-cart', selector: 'app-cart',
@@ -55,7 +56,7 @@ export class CartComponent implements OnDestroy {
emailSubmitting = signal<boolean>(false); emailSubmitting = signal<boolean>(false);
paidItems: CartItem[] = []; paidItems: CartItem[] = [];
maxChecks = 36; // 36 checks * 5 seconds = 180 seconds (3 minutes) maxChecks = PAYMENT_MAX_CHECKS;
private pollingSubscription?: Subscription; private pollingSubscription?: Subscription;
private closeTimeout?: ReturnType<typeof setTimeout>; private closeTimeout?: ReturnType<typeof setTimeout>;
@@ -181,51 +182,62 @@ export class CartComponent implements OnDestroy {
} }
createPayment(): void { createPayment(): void {
const telegramUsername = this.getTelegramUsername(); const sessionId = this.authService.session()?.sessionId || '';
const userId = this.getUserId(); if (!sessionId) {
const orderId = this.generateOrderId(); this.paymentStatus.set('timeout');
return;
}
const paymentData = { // First sync cart items to server via websession, then create QR
amount: this.totalPrice(), const cartItems = this.items().map((item: CartItem) => ({
currency: this.langService.currentCurrency(), itemID: item.itemID,
siteuserID: userId, quantity: item.quantity,
siteorderID: orderId, colour: item.colour || '',
redirectUrl: '', size: item.size || '',
telegramUsername: telegramUsername, price: item.discount > 0
items: this.items().map((item: CartItem) => ({ ? item.price * (1 - item.discount / 100)
itemID: item.itemID, : item.price,
price: item.discount > 0 }));
? item.price * (1 - item.discount / 100)
: item.price,
name: item.name,
quantity: item.quantity
}))
};
this.apiService.createPayment(paymentData).subscribe({ this.apiService.addToCart(sessionId, cartItems).subscribe({
next: (response) => { next: () => {
this.paymentId.set(response.qrId); this.apiService.createPayment(sessionId).subscribe({
this.qrCodeUrl.set(response.qrUrl); next: (response) => {
this.paymentUrl.set(response.payload); this.paymentId.set(response.qrId);
this.paymentStatus.set('waiting'); this.qrCodeUrl.set(response.qrUrl);
this.startPolling(); this.paymentUrl.set(response.Payload);
this.paymentStatus.set('waiting');
this.startPolling();
},
error: (err) => {
console.error('Error creating payment:', err);
this.paymentStatus.set('timeout');
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => {
this.closePaymentPopup();
}, PAYMENT_ERROR_CLOSE_MS);
}
});
}, },
error: (err) => { error: (err) => {
console.error('Error creating payment:', err); console.error('Error syncing cart:', err);
this.paymentStatus.set('timeout'); this.paymentStatus.set('timeout');
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => { this.closeTimeout = setTimeout(() => {
this.closePaymentPopup(); this.closePaymentPopup();
}, 4000); }, PAYMENT_ERROR_CLOSE_MS);
} }
}); });
} }
startPolling(): void { startPolling(): void {
this.pollingSubscription = interval(5000) // every 5 seconds this.stopPolling();
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
.pipe( .pipe(
take(this.maxChecks), // maximum 36 checks (3 minutes) take(this.maxChecks), // maximum 36 checks (3 minutes)
switchMap(() => { switchMap(() => {
return this.apiService.checkPaymentStatus(this.paymentId()); const sessionId = this.authService.session()?.sessionId || '';
return this.apiService.checkPaymentStatus(sessionId, this.paymentId());
}) })
) )
.subscribe({ .subscribe({
@@ -245,17 +257,19 @@ export class CartComponent implements OnDestroy {
if (this.paymentStatus() === 'waiting') { if (this.paymentStatus() === 'waiting') {
this.paymentStatus.set('timeout'); this.paymentStatus.set('timeout');
// Close popup after showing timeout message // Close popup after showing timeout message
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => { this.closeTimeout = setTimeout(() => {
this.closePaymentPopup(); this.closePaymentPopup();
}, 3000); }, PAYMENT_TIMEOUT_CLOSE_MS);
} }
}, },
error: (err) => { error: (err) => {
console.error('Error checking payment status:', err); console.error('Error checking payment status:', err);
// Continue checking even on error until time runs out // Continue checking even on error until time runs out
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => { this.closeTimeout = setTimeout(() => {
this.closePaymentPopup(); this.closePaymentPopup();
}, 3000); }, PAYMENT_TIMEOUT_CLOSE_MS);
} }
}); });
} }
@@ -271,34 +285,13 @@ export class CartComponent implements OnDestroy {
if (url) { if (url) {
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => {
this.linkCopied.set(true); this.linkCopied.set(true);
setTimeout(() => this.linkCopied.set(false), 2000); setTimeout(() => this.linkCopied.set(false), LINK_COPIED_DURATION_MS);
}).catch(err => { }).catch(err => {
console.error(this.i18n.t('cart.copyError'), err); console.error(this.i18n.t('cart.copyError'), err);
}); });
} }
} }
private getTelegramUsername(): string {
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
const user = window.Telegram.WebApp.initDataUnsafe.user;
return user.username || 'nontelegram';
}
return 'nontelegram';
}
private getUserId(): string {
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
return window.Telegram.WebApp.initDataUnsafe.user.id.toString();
}
return `web_${Date.now()}`;
}
private generateOrderId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `order_${timestamp}_${random}`;
}
submitEmail(): void { submitEmail(): void {
// Mark both fields as touched // Mark both fields as touched
this.emailTouched.set(true); this.emailTouched.set(true);

View File

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

View File

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

View File

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

View File

@@ -48,13 +48,13 @@
<a [routerLink]="['/category', cat.categoryID] | langRoute" class="category-card"> <a [routerLink]="['/category', cat.categoryID] | langRoute" class="category-card">
<div class="category-image"> <div class="category-image">
@if (cat.icon) { @if (cat.icon) {
<img [src]="cat.icon" [alt]="cat.name" loading="lazy" decoding="async" /> <img [src]="cat.icon" [alt]="categoryName(cat)" loading="lazy" decoding="async" />
} @else { } @else {
<div class="category-fallback">{{ cat.name.charAt(0) }}</div> <div class="category-fallback">{{ categoryName(cat).charAt(0) }}</div>
} }
</div> </div>
<div class="category-info"> <div class="category-info">
<h3 class="category-name">{{ cat.name }}</h3> <h3 class="category-name">{{ categoryName(cat) }}</h3>
</div> </div>
</a> </a>
} }

View File

@@ -7,7 +7,7 @@ import { Subscription } from 'rxjs';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service'; import { TranslateService } from '../../i18n/translate.service';
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils'; import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField, getTranslatedCategoryName } from '../../utils/item.utils';
@Component({ @Component({
selector: 'app-subcategories', selector: 'app-subcategories',
@@ -59,7 +59,7 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
next: (cats) => { next: (cats) => {
this.categories.set(cats); this.categories.set(cats);
const parent = cats.find(c => c.categoryID === parentID); const parent = cats.find(c => c.categoryID === parentID);
this.parentName.set(parent ? parent.name : this.i18n.t('home.categoriesTitle')); this.parentName.set(parent ? getTranslatedCategoryName(parent, this.langService.currentLanguage()) : this.i18n.t('home.categoriesTitle'));
// Check for nested subcategories from API response (backOffice format) // Check for nested subcategories from API response (backOffice format)
const nested = parent?.subcategories || []; const nested = parent?.subcategories || [];
@@ -135,4 +135,6 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
readonly getBadgeClass = getBadgeClass; readonly getBadgeClass = getBadgeClass;
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); } itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
categoryName(cat: Category): string { return getTranslatedCategoryName(cat, this.langService.currentLanguage()); }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -82,12 +82,12 @@
<div class="novo-price-block"> <div class="novo-price-block">
@if (item()!.discount > 0) { @if (item()!.discount > 0) {
<div class="price-row"> <div class="price-row">
<span class="old-price">{{ item()!.price }} {{ item()!.currency }}</span> <span class="old-price">{{ effectivePrice() }} {{ effectiveCurrency() }}</span>
<span class="discount-badge">-{{ item()!.discount }}%</span> <span class="discount-badge">-{{ item()!.discount }}%</span>
</div> </div>
<div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ item()!.currency }}</div> <div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ effectiveCurrency() }}</div>
} @else { } @else {
<div class="current-price">{{ item()!.price }} {{ item()!.currency }}</div> <div class="current-price">{{ effectivePrice() }} {{ effectiveCurrency() }}</div>
} }
</div> </div>
@@ -97,23 +97,37 @@
<span class="dot"></span> <span class="dot"></span>
{{ getStockLabel() }} {{ getStockLabel() }}
</div> </div>
@if (item()!.quantity != null) { @if (effectiveRemaining() != null) {
<span class="stock-qty">({{ item()!.quantity }} шт.)</span> <span class="stock-qty">({{ effectiveRemaining() }} шт.)</span>
} }
</div> </div>
@if (item()!.colour || item()!.size) { @if (availableColours().length || availableSizes().length || item()!.colour || (item()!.size && item()!.size!.toLowerCase() !== 'default')) {
<div class="novo-variants"> <div class="novo-variants">
@if (item()!.colour) { @if (availableColours().length) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span> <span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
<span class="variant-chip colour-chip">{{ item()!.colour }}</span> @for (c of availableColours(); track c) {
<span class="colour-swatch" [class.active]="selectedColour() === c" [style.background-color]="c" [title]="c" (click)="selectColour(c)"></span>
}
</div>
} @else if (item()!.colour) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
<span class="colour-swatch active" [style.background-color]="item()!.colour" [title]="item()!.colour"></span>
</div> </div>
} }
@if (item()!.size) { @if (availableSizes().length) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span> <span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
<span class="variant-chip size-chip">{{ item()!.size }}</span> @for (s of availableSizes(); track s) {
<span class="variant-chip size-chip" [class.active]="selectedSize() === s" (click)="selectSize(s)">{{ s }}</span>
}
</div>
} @else if (item()!.size && item()!.size!.toLowerCase() !== 'default') {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
<span class="variant-chip size-chip active">{{ item()!.size }}</span>
</div> </div>
} }
</div> </div>
@@ -347,12 +361,12 @@
<div class="dx-price-block"> <div class="dx-price-block">
@if (item()!.discount > 0) { @if (item()!.discount > 0) {
<div class="dx-price-row"> <div class="dx-price-row">
<span class="dx-old-price">{{ item()!.price }} {{ item()!.currency }}</span> <span class="dx-old-price">{{ effectivePrice() }} {{ effectiveCurrency() }}</span>
<span class="dx-discount-tag">-{{ item()!.discount }}%</span> <span class="dx-discount-tag">-{{ item()!.discount }}%</span>
</div> </div>
} }
<div class="dx-current-price"> <div class="dx-current-price">
{{ item()!.discount > 0 ? (getDiscountedPrice() | number:'1.2-2') : item()!.price }} {{ item()!.currency }} {{ item()!.discount > 0 ? (getDiscountedPrice() | number:'1.2-2') : effectivePrice() }} {{ effectiveCurrency() }}
</div> </div>
</div> </div>
@@ -362,23 +376,37 @@
<span class="dx-stock-dot"></span> <span class="dx-stock-dot"></span>
{{ getStockLabel() }} {{ getStockLabel() }}
</span> </span>
@if (item()!.quantity != null) { @if (effectiveRemaining() != null) {
<span class="dx-stock-qty">({{ item()!.quantity }} шт.)</span> <span class="dx-stock-qty">({{ effectiveRemaining() }} шт.)</span>
} }
</div> </div>
@if (item()!.colour || item()!.size) { @if (availableColours().length || availableSizes().length || item()!.colour || (item()!.size && item()!.size!.toLowerCase() !== 'default')) {
<div class="dx-variants"> <div class="dx-variants">
@if (item()!.colour) { @if (availableColours().length) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span> <span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
<span class="variant-chip colour-chip">{{ item()!.colour }}</span> @for (c of availableColours(); track c) {
<span class="colour-swatch" [class.active]="selectedColour() === c" [style.background-color]="c" [title]="c" (click)="selectColour(c)"></span>
}
</div>
} @else if (item()!.colour) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
<span class="colour-swatch active" [style.background-color]="item()!.colour" [title]="item()!.colour"></span>
</div> </div>
} }
@if (item()!.size) { @if (availableSizes().length) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span> <span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
<span class="variant-chip size-chip">{{ item()!.size }}</span> @for (s of availableSizes(); track s) {
<span class="variant-chip size-chip" [class.active]="selectedSize() === s" (click)="selectSize(s)">{{ s }}</span>
}
</div>
} @else if (item()!.size && item()!.size!.toLowerCase() !== 'default') {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
<span class="variant-chip size-chip active">{{ item()!.size }}</span>
</div> </div>
} }
</div> </div>
@@ -405,7 +433,8 @@
</button> </button>
<div class="dx-description"> <div class="dx-description">
@if (getSimpleDescription()) { <!-- @if (getSimpleDescription()) { -->
@if (false) {
<p class="dx-simple-desc">{{ getSimpleDescription() }}</p> <p class="dx-simple-desc">{{ getSimpleDescription() }}</p>
} }

View File

@@ -1,4 +1,6 @@
// ========== DEXAR ITEM DETAIL - Redesigned 2026 ========== @use 'sass:color';
// ========== DEXAR ITEM DETAIL - Redesigned 2026 ==========
$dx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; $dx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
$dx-dark: #1e3c38; $dx-dark: #1e3c38;
$dx-primary: #497671; $dx-primary: #497671;
@@ -50,7 +52,7 @@ $dx-card-bg: #f5f3f9;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
background: darken($dx-primary, 8%); background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-1px); transform: translateY(-1px);
} }
} }
@@ -281,7 +283,7 @@ $dx-card-bg: #f5f3f9;
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15); box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
&:hover { &:hover {
background: darken($dx-primary, 8%); background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(73, 118, 113, 0.3); box-shadow: 0 6px 16px rgba(73, 118, 113, 0.3);
} }
@@ -294,7 +296,7 @@ $dx-card-bg: #f5f3f9;
// Variant chips (colour/size) — shared between dexar and novo // Variant chips (colour/size) — shared between dexar and novo
.dx-variants, .novo-variants { .dx-variants, .novo-variants {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
@@ -302,6 +304,7 @@ $dx-card-bg: #f5f3f9;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
.variant-label { .variant-label {
@@ -320,6 +323,37 @@ $dx-card-bg: #f5f3f9;
border: 1.5px solid $dx-border; border: 1.5px solid $dx-border;
background: rgba(73, 118, 113, 0.06); background: rgba(73, 118, 113, 0.06);
color: $dx-primary; color: $dx-primary;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
&:hover {
background: rgba(73, 118, 113, 0.12);
}
&.active {
border-color: $dx-primary;
background: rgba(73, 118, 113, 0.18);
box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.25);
}
}
.colour-swatch {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid $dx-border;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
flex-shrink: 0;
&:hover {
border-color: $dx-primary;
}
&.active {
border-color: $dx-primary;
box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.25);
}
} }
} }
@@ -492,7 +526,7 @@ $dx-card-bg: #f5f3f9;
justify-content: center; justify-content: center;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: darken($dx-primary, 8%); background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-1px); transform: translateY(-1px);
} }

View File

@@ -1,9 +1,10 @@
import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, signal, computed, ChangeDetectionStrategy, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services'; import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services';
import { Item, DescriptionField } from '../../models'; import { AuthService } from '../../services/auth.service';
import { Item, ItemDetail, DescriptionField } from '../../models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@@ -26,6 +27,55 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
loading = signal(true); loading = signal(true);
error = signal<string | null>(null); error = signal<string | null>(null);
isnovo = environment.theme === 'novo'; isnovo = environment.theme === 'novo';
// Variant selection
selectedColour = signal<string | null>(null);
selectedSize = signal<string | null>(null);
availableColours = computed(() => {
const details = this.item()?.itemDetails;
if (!details?.length) return [] as string[];
const unique = [...new Set(details.map(d => d.colour || d.color).filter((c): c is string => !!c))];
return unique;
});
availableSizes = computed(() => {
const details = this.item()?.itemDetails;
if (!details?.length) return [] as string[];
// If a colour is selected, only show sizes available for that colour
const colour = this.selectedColour();
const filtered = colour
? details.filter(d => (d.colour || d.color) === colour)
: details;
const unique = [...new Set(filtered.map(d => d.size).filter((s): s is string => !!s && s.toLowerCase() !== 'default'))];
return unique;
});
selectedDetail = computed<ItemDetail | null>(() => {
const details = this.item()?.itemDetails;
if (!details?.length) return null;
const colour = this.selectedColour();
const size = this.selectedSize();
return details.find(d =>
(!colour || (d.colour || d.color) === colour) &&
(!size || d.size === size)
) ?? null;
});
effectivePrice = computed(() => {
const detail = this.selectedDetail();
return detail?.price ?? this.item()?.price ?? 0;
});
effectiveCurrency = computed(() => {
const detail = this.selectedDetail();
return detail?.currency ?? this.item()?.currency ?? '';
});
effectiveRemaining = computed(() => {
const detail = this.selectedDetail();
return detail?.remaining ?? this.item()?.quantity ?? null;
});
newReview = { newReview = {
rating: 0, rating: 0,
@@ -42,6 +92,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
private seoService = inject(SeoService); private seoService = inject(SeoService);
private i18n = inject(TranslateService); private i18n = inject(TranslateService);
private authService = inject(AuthService);
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -73,6 +124,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
this.apiService.getItem(itemID).subscribe({ this.apiService.getItem(itemID).subscribe({
next: (item) => { next: (item) => {
this.item.set(item); this.item.set(item);
this.initVariantSelection(item);
this.seoService.setItemMeta(item); this.seoService.setItemMeta(item);
this.loading.set(false); this.loading.set(false);
}, },
@@ -84,6 +136,33 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
}); });
} }
private initVariantSelection(item: Item): void {
// Auto-select the first available colour and size from itemDetails
const details = item.itemDetails;
if (details?.length) {
const firstColour = details[0].colour || details[0].color || null;
this.selectedColour.set(firstColour);
const firstSize = details[0].size || null;
this.selectedSize.set(firstSize);
} else {
this.selectedColour.set(item.colour ?? null);
this.selectedSize.set(item.size ?? null);
}
}
selectColour(colour: string): void {
this.selectedColour.set(colour);
// If current size is not available for the new colour, reset to first available
const sizes = this.availableSizes();
if (sizes.length && this.selectedSize() && !sizes.includes(this.selectedSize()!)) {
this.selectedSize.set(sizes[0]);
}
}
selectSize(size: string): void {
this.selectedSize.set(size);
}
selectPhoto(index: number): void { selectPhoto(index: number): void {
this.selectedPhotoIndex.set(index); this.selectedPhotoIndex.set(index);
} }
@@ -91,14 +170,21 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
addToCart(): void { addToCart(): void {
const currentItem = this.item(); const currentItem = this.item();
if (currentItem) { if (currentItem) {
this.cartService.addItem(currentItem.itemID); this.cartService.addItem(currentItem.itemID, 1, {
colour: this.selectedColour() ?? undefined,
size: this.selectedSize() ?? undefined,
price: this.effectivePrice(),
currency: this.effectiveCurrency()
});
} }
} }
getDiscountedPrice(): number { getDiscountedPrice(): number {
const currentItem = this.item(); const currentItem = this.item();
if (!currentItem) return 0; if (!currentItem) return 0;
return getDiscountedPrice(currentItem); const price = this.effectivePrice();
const discount = currentItem.discount || 0;
return discount > 0 ? price * (1 - discount / 100) : price;
} }
// BackOffice integration helpers // BackOffice integration helpers
@@ -113,8 +199,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
getSimpleDescription(): string { getSimpleDescription(): string {
const currentItem = this.item(); const currentItem = this.item();
if (!currentItem) return ''; if (!currentItem) return '';
const lang = this.languageService.currentLanguage(); return currentItem.simpleDescription || currentItem.description || '';
return getTranslatedField(currentItem, 'simpleDescription', lang);
} }
hasDescriptionFields(): boolean { hasDescriptionFields(): boolean {
@@ -207,8 +292,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
itemID: currentItem.itemID, itemID: currentItem.itemID,
rating: this.newReview.rating, rating: this.newReview.rating,
comment: this.newReview.comment.trim(), comment: this.newReview.comment.trim(),
username: this.newReview.anonymous ? null : this.getUserDisplayName(), sessionID: this.authService.session()?.sessionId || '',
userId: this.telegramService.getUserId(),
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { Item } from '../models'; import { Item } from '../models';
import { Category } from '../models/category.model';
export function getDiscountedPrice(item: Item): number { export function getDiscountedPrice(item: Item): number {
return item.price * (1 - (item.discount || 0) / 100); return item.price * (1 - (item.discount || 0) / 100);
@@ -69,24 +70,37 @@ export function getTranslatedField(
field: 'name' | 'simpleDescription', field: 'name' | 'simpleDescription',
lang: string lang: string
): string { ): string {
// 1. Check translations map (backOffice format) // 1. Check translations map (already normalized to frontend codes)
const translation = item.translations?.[lang]; const translation = item.translations?.[lang];
if (translation && translation[field]) { if (translation && translation[field]) {
return translation[field]!; return translation[field]!;
} }
// 2. Check names[]/descriptions[] arrays (backend API format) // 2. Check names[]/descriptions[] arrays (may have API codes: RU/EN/AM)
// Note: API has typo "valuue" in some responses — handle both
if (field === 'name' && item.names?.length) { if (field === 'name' && item.names?.length) {
const entry = item.names.find(n => n.language === lang); const entry = item.names.find(n => n.language === lang || n.language === lang.toUpperCase() || (lang === 'hy' && n.language === 'AM'));
if (entry) return entry.value; const val = entry?.value || (entry as any)?.valuue || '';
if (val) return val;
} }
if (field === 'simpleDescription' && item.descriptions?.length) {
const entry = item.descriptions.find(d => d.language === lang);
if (entry) return entry.value;
}
// 3. Fallback to base field // 3. Fallback to base field
if (field === 'name') return item.name; if (field === 'name') return item.name;
if (field === 'simpleDescription') return item.simpleDescription || item.description || ''; if (field === 'simpleDescription') return item.simpleDescription || item.description || '';
return ''; return '';
} }
/**
* Get translated category name for the current language.
*/
export function getTranslatedCategoryName(cat: Category, lang: string): string {
const translation = cat.translations?.[lang];
if (translation?.name) return translation.name;
if (cat.names?.length) {
const entry = cat.names.find(n => n.language === lang || n.language === lang.toUpperCase() || (lang === 'hy' && n.language === 'AM'));
const val = entry?.value || (entry as any)?.valuue || '';
if (val) return val;
}
return cat.name || '';
}

View File

@@ -10,7 +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', telegramBot: 'DexarSupport_bot',
phones: { phones: {
russia: '+7 (926) 459-31-57', russia: '+7 (926) 459-31-57',
armenia: '+374 94 86 18 16' armenia: '+374 94 86 18 16'

View File

@@ -11,7 +11,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', telegramBot: 'DexarSupport_bot',
phones: { phones: {
russia: '+7 (926) 459-31-57', russia: '+7 (926) 459-31-57',
armenia: '+374 94 86 18 16' armenia: '+374 94 86 18 16'

4
start
View File

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