Compare commits
32 Commits
back-offic
...
df2208ab53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df2208ab53 | ||
|
|
72deb8d5e3 | ||
|
|
5566e011b7 | ||
|
|
ee23fd2d3c | ||
|
|
2a41062769 | ||
|
|
6624de7a32 | ||
|
|
44553f5bd4 | ||
|
|
5ed255dddb | ||
|
|
650bf137f2 | ||
|
|
3a8bc2f893 | ||
|
|
d29de100c6 | ||
|
|
97214c3a90 | ||
|
|
0b3b2ee463 | ||
|
|
c3e4e695eb | ||
|
|
c112aded47 | ||
|
|
75f029b872 | ||
|
|
f823df7e15 | ||
|
|
af78c053ba | ||
|
|
4ef4223367 | ||
|
|
7b18376d28 | ||
|
|
c64b9cfee8 | ||
|
|
712281d2e8 | ||
|
|
0626dcbe46 | ||
|
|
d288a5fb3c | ||
|
|
75b45abe4f | ||
|
|
2bd98b29eb | ||
|
|
82cbf07120 | ||
|
|
e07356a700 | ||
|
|
5068a3a114 | ||
|
|
333ea45c38 | ||
|
|
b22390f3eb | ||
|
|
3f285ca15f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -38,7 +38,7 @@ yarn-error.log
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
/public/images/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
23
angular.json
23
angular.json
@@ -154,7 +154,7 @@
|
||||
},
|
||||
"serve": {
|
||||
"options": {
|
||||
"allowedHosts": ["novo.market", "dexarmarket.ru", "localhost"],
|
||||
"allowedHosts": ["novo.market", "dexarmarket.ru", "dexar.market","localhost"],
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"builder": "@angular/build:dev-server",
|
||||
@@ -176,28 +176,9 @@
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,748 @@
|
||||
bro we need to do changes, that client required
|
||||
1. we need to add location logic
|
||||
1.1 the catalogs will come or for global or for exact region
|
||||
1.2 need to add a place where the user can choose his region like city if choosed moscow the country is set russian
|
||||
1.3 can we try to understand what country is user logged or whach city by global ip and set it?
|
||||
2. we need to add somekind of user login logic
|
||||
2.1 user can add to cart, look the items and etc without logged in, but when he is going to buy/pay ->
|
||||
at first he have to login with telegram, i will send you the bots adress.
|
||||
2.1.1 if is not logged -> will see the QR or link for logging via telegram
|
||||
2.1.2 if logged we need to ping server to check if he is active user. the expiration date (like day or 5 days) we will get from bakcend with session id
|
||||
2.2 and when user is logged, that time he can do a payment
|
||||
General Information
|
||||
Information exchange with the SBP server is realized via RESTful API. All requests to the server must be executed via HTTPS using GET||POST||PUT||DELETE requests to the given ROOT address. Body of requests must be in JSON format. All not public requests must be signed by the client and the public key must be sent to the server for client identification and sign checking.
|
||||
Header:
|
||||
“Authorization”: {JSON WITH KEY AND PARTNERID}
|
||||
“X-Region” : Moscow | Yerevan | ST. Petersburg
|
||||
“X-Language” : RU | AM | EN
|
||||
“WebSessionID” : f02fe5d6-c6ae-4b2e-9b4d-687534e11b01
|
||||
“Currency” :RUB | AMD | USD
|
||||
Root:
|
||||
API.dexarmarket.ru
|
||||
|
||||
|
||||
General Information
|
||||
Check if server is available
|
||||
Get Marketplaces
|
||||
Set Marketplaces
|
||||
Get Item
|
||||
Delete Item
|
||||
New Item
|
||||
New Callback
|
||||
New Question
|
||||
Get random Items
|
||||
Get items in category
|
||||
Get searched items
|
||||
Get Categories
|
||||
Delete Category
|
||||
New Category
|
||||
Create new websession
|
||||
Check websession status
|
||||
Delete websession status
|
||||
Add to cart
|
||||
Create New QR code for cart checkout
|
||||
Check QR code
|
||||
item structure
|
||||
category structure
|
||||
Check if server is available
|
||||
Client needs to periodically check if the server is available by sending “ping” to the client. On error corresponding message must be shown.
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /ping
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response (Error):
|
||||
{
|
||||
"message": "pong",
|
||||
"status": "Wrong Header"
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"message": "pong",
|
||||
"status": "Correct Header"
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get Marketplaces
|
||||
Get Available Marketplaces
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /marketplaces
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
[{“brand” : “dexar”,
|
||||
“api”:”dexar.market”,
|
||||
“bot”:”dexarmarket_bot”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”, ”Yerevan - Armenia”]
|
||||
“currency”:[“RUB, ”AMD”, ”USD”]
|
||||
“icon”:”./dexar.market.png”},
|
||||
{“brand” : “store”,
|
||||
“api”:”dexarmarket.store”,
|
||||
“bot”:”dexarstore_bot”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./dexarmarket.store.png”},
|
||||
{“brand” : “Novo”,
|
||||
“api”:”novo.market”,
|
||||
“bot”:”novomarket_bot”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./novo.market.png”}]
|
||||
|
||||
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Set Marketplaces
|
||||
Get Available Marketplaces
|
||||
Protocol: https
|
||||
Type: PUT
|
||||
Path: /marketplaces
|
||||
Request Parameters:
|
||||
{
|
||||
[{“brand” : “dexar”,
|
||||
“api”:”dexar.market”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./dexar.market.png”},
|
||||
{“brand” : “store”,
|
||||
“api”:”dexarmarket.store”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./dexarmarket.store.png”},
|
||||
{“brand” : “Novo”,
|
||||
“api”:”novo.market”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./novo.market.png”}]
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Marketplace updated”
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get Item
|
||||
Get Item by ID
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
________________
|
||||
Delete Item
|
||||
Delete the item
|
||||
Protocol: https
|
||||
Type: Delete
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Item was deleted”
|
||||
}
|
||||
________________
|
||||
New Item
|
||||
Create new Item
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
________________
|
||||
Update Item
|
||||
Update the item
|
||||
Protocol: https
|
||||
Type: PUT
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Item updated”
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
New Callback
|
||||
Update the item
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /items/:itemID/callback
|
||||
Request Parameters:
|
||||
{
|
||||
"rating": 5,
|
||||
"comment": "Отличный товар!",
|
||||
"sessionID": “ f02fe5d6-c6ae-4b2e-9b4d-687534e11b01”
|
||||
"timestamp": "2026-02-28T12:00:00Z"
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong item"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Callback added”
|
||||
}
|
||||
________________
|
||||
New Question
|
||||
Update the item
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /items/:itemID/questiion
|
||||
Request Parameters:
|
||||
{
|
||||
"question": "some question!",
|
||||
"sessionID": “ f02fe5d6-c6ae-4b2e-9b4d-687534e11b01”
|
||||
"timestamp": "2026-02-28T12:00:00Z"
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong item"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Questiion added”
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get random Items
|
||||
Get given number of items from random categorues
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /items/randomitems?count=15 // 20 is the default
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
[“itemID”:...]
|
||||
}
|
||||
________________
|
||||
Get items in category
|
||||
Get all items in category and in all subcategories inside the category
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /category/:categoryID?count=30, skip=60 // default skip=0, default count=20
|
||||
|
||||
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
[“itemID”:...]
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get searched items
|
||||
Get all items in category and in all subcategories inside the category
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /searchitems
|
||||
Parameters:
|
||||
{
|
||||
search (string) — query text
|
||||
categoryIDs (string) — e.g., 1,2,5 (includes all subcategories)
|
||||
minPrice / maxPrice (float) — price range
|
||||
tag (string) — e.g., sale
|
||||
sort (string) — relevance (default), price_asc, price_desc, popular, rating
|
||||
skip / count — default 0 / 20
|
||||
}
|
||||
Examples:
|
||||
* ?search=iphone&sort=popular
|
||||
* ?categoryIDs=1,5&minPrice=100&maxPrice=500
|
||||
* ?tag=new&sort=price_asc&count=10
|
||||
|
||||
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
"total": 12,
|
||||
"skip": 0,
|
||||
"count": 12,
|
||||
"isGlobal": false,
|
||||
"items": [
|
||||
{ "itemID": 101, "name": "..." }
|
||||
]
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get Categories
|
||||
Get all available categories
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /category
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“categoryID”:...
|
||||
}
|
||||
________________
|
||||
Delete Category
|
||||
Delete EMPTY category, no items and no subcategories must present
|
||||
Protocol: https
|
||||
Type: Delete
|
||||
Path: /category/:categoryID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Category was deleted”
|
||||
}
|
||||
________________
|
||||
New Category
|
||||
Create new category
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /category/
|
||||
Request Parameters:
|
||||
{
|
||||
“CategoryID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“CategoryID”:...
|
||||
}
|
||||
________________
|
||||
Update Category
|
||||
Update existing category
|
||||
Protocol: https
|
||||
Type: PUT
|
||||
Path: /category/:categoryID
|
||||
Request Parameters:
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Category was updated”
|
||||
}
|
||||
________________
|
||||
Create new websession
|
||||
Creates a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
|
||||
Protocol: https
|
||||
Type POST
|
||||
Path /websession
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||
"userId" : "",
|
||||
"expires" : "sessionId",
|
||||
"userSessionId": "",
|
||||
"status": false
|
||||
}
|
||||
________________
|
||||
Check websession status
|
||||
Check if the user is already logged in. a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
|
||||
Protocol: https
|
||||
Type GET
|
||||
Path /websession/:webSessionID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
|
||||
"expires" : "sessionId",
|
||||
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
|
||||
"x-Region" : "Moscow",
|
||||
"x-Language" : "RU",
|
||||
"currency" : "RUB",
|
||||
"Status": true,
|
||||
"cart": [
|
||||
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||
]
|
||||
}
|
||||
________________
|
||||
Delete websession status
|
||||
Delete the session to log out from the system.
|
||||
Protocol: https
|
||||
Type DELETE
|
||||
Path /websession/:webSessionID
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
“status”:”User logged out”
|
||||
}
|
||||
|
||||
________________
|
||||
Add to cart
|
||||
Add a all item to users (session) cart
|
||||
Protocol: https
|
||||
Type Post
|
||||
Path /websession/:webSessionID
|
||||
Request Parameters:
|
||||
{
|
||||
[
|
||||
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||
]
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
|
||||
"expires" : "sessionId",
|
||||
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
|
||||
"Status": true,
|
||||
"cart": [
|
||||
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||
]
|
||||
}
|
||||
________________
|
||||
Create New QR code for cart checkout
|
||||
Create New QR for payment via SBP
|
||||
Protocol: https
|
||||
Type POST
|
||||
Path /websession/:webSessionID/qr
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong key"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
"qrId": "BD10002CI1V3JP1T8QR8TIQ8K35RBVQB",
|
||||
"qrStatus": "NEW",
|
||||
"qrExpirationDate": "2025-11-20T10:10:44Z",
|
||||
"Payload": "https://qr.nspk.ru/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB?type=02&bank=100000000007&sum=1000&cur=RUB&crc=8ACC",
|
||||
"qrUrl": "https://e-commerce.raiffeisen.ru/api/sbp/v1/qr/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB/image"
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Check QR code
|
||||
Check QR status
|
||||
Protocol: https
|
||||
Type GET
|
||||
Path /websession/:webSessionID/:qrID
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "Error from the bank "
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
"additionalInfo": "",
|
||||
"paymentPurpose": "",
|
||||
"amount": 10,
|
||||
"code": "SUCCESS",
|
||||
"createDate": "2025-11-20T13:17:20.453884+03:00",
|
||||
"currency": "RUB",
|
||||
"order": "102_540",
|
||||
"paymentStatus": "NO_INFO", //check for SUCCESS
|
||||
"qrId": "BD1000263VS7G81D8JCP5FHFTFEH38MT",
|
||||
"transactionDate": "",
|
||||
"transactionId": 0,
|
||||
"qrExpirationDate": "2025-11-20T13:32:20+03:00"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 8. Авторизация (вход через Telegram)
|
||||
|
||||
|
||||
Авторизация **через Telegram** с **cookie-сессиями** (HttpOnly, Secure, SameSite=None).
|
||||
|
||||
|
||||
Все auth-эндпоинты должны поддерживать CORS с `credentials: true`.
|
||||
|
||||
|
||||
### Процесс авторизации
|
||||
|
||||
|
||||
```
|
||||
1. Пользователь нажимает «Оформить заказ» → не авторизован → показывается диалог входа
|
||||
2. Нажимает «Войти через Telegram» → открывается https://t.me/{bot}?start=auth_{callback}
|
||||
3. Пользователь запускает бота в Telegram
|
||||
4. Бот отправляет данные пользователя → бэкенд /auth/telegram/callback
|
||||
5. Бэкенд создаёт сессию → устанавливает Set-Cookie
|
||||
6. Фронтенд опрашивает GET /auth/session каждые 3 секунды
|
||||
7. Сессия обнаружена → диалог закрывается → оформление заказа продолжается
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
### `GET /auth/session` — Проверить текущую сессию
|
||||
|
||||
|
||||
**Запрос:** Только cookie (сессионная cookie, установленная бэкендом).
|
||||
|
||||
|
||||
**Ответ `200`** (авторизован):
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
"telegramUserId": 123456789,
|
||||
"username": "john_doe",
|
||||
"displayName": "John Doe",
|
||||
"active": true,
|
||||
"expiresAt": "2026-03-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
**Ответ `200`** (сессия истекла):
|
||||
```json
|
||||
{
|
||||
"sessionId": "sess_abc123",
|
||||
"telegramUserId": 123456789,
|
||||
"username": "john_doe",
|
||||
"displayName": "John Doe",
|
||||
"active": false,
|
||||
"expiresAt": "2026-02-27T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
**Ответ `401`** (нет сессии):
|
||||
```json
|
||||
{ "error": "No active session" }
|
||||
```
|
||||
|
||||
|
||||
**Объект AuthSession:**
|
||||
|
||||
|
||||
| Поле | Тип | Обязат. | Описание |
|
||||
|------------------|---------|---------|-------------------------------------------|
|
||||
| `sessionId` | string | да | Уникальный ID сессии |
|
||||
| `telegramUserId` | number | да | ID пользователя в Telegram |
|
||||
| `username` | string? | нет | @username в Telegram (может быть null) |
|
||||
| `displayName` | string | да | Отображаемое имя (имя + фамилия) |
|
||||
| `active` | boolean | да | Действительна ли сессия |
|
||||
| `expiresAt` | string | да | Дата истечения в формате ISO 8601 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
### `GET /auth/telegram/callback` — Callback авторизации Telegram-бота
|
||||
|
||||
|
||||
Вызывается Telegram-ботом после авторизации пользователя.
|
||||
|
||||
|
||||
**Тело запроса (от бота):**
|
||||
```json
|
||||
{
|
||||
"id": 123456789,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"username": "john_doe",
|
||||
"photo_url": "https://t.me/i/userpic/...",
|
||||
"auth_date": 1709100000,
|
||||
"hash": "abc123def456..."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
Бот должен:
|
||||
1. Слушать команду `/start auth_{callbackUrl}`
|
||||
2. Извлечь callback URL
|
||||
3. Отправить данные пользователя (`id`, `first_name`, `username` и т.д.) на этот callback URL
|
||||
4. Callback URL: `{apiUrl}/auth/telegram/callback`
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Полный справочник эндпоинтов
|
||||
|
||||
|
||||
### Новые эндпоинты
|
||||
|
||||
|
||||
| Метод | Путь | Описание | Авторизация |
|
||||
|--------|---------------------------|---------------------------------|-------------|
|
||||
| `GET` | `/regions` | Список доступных регионов | Нет |
|
||||
| `GET` | `/auth/session` | Проверка текущей сессии | Cookie |
|
||||
| `GET` | `/auth/telegram/callback` | Callback авторизации через бота | Нет (бот) |
|
||||
| `POST` | `/auth/logout` | Завершение сессии | Cookie |
|
||||
|
||||
|
||||
________________
|
||||
|
||||
|
||||
item structure
|
||||
CategoryID uint64 `json:"categoryID" binding:"required"`
|
||||
ItemID uint64 `json:"itemID" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Discount float32 `json:"discount" `
|
||||
Rating float32 `json:"rating" binding:"required"`
|
||||
Visible bool `json:"rating"`
|
||||
Priority uint64 `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
Badges []string `json:"badges"`
|
||||
Details []itemdetail `json:"itemdetails"`
|
||||
Colour string `json:"colour" binding:"required"`
|
||||
Size string `json:"size" binding:"required"`
|
||||
Price float32 `json:"price" binding:"required"`
|
||||
Currency string `json:"currency" binding:"required"`
|
||||
Remaining uint64 `json:"remaining" binding:"required"`
|
||||
Names []itemname `json:"names"`
|
||||
Language string `json:"language"`
|
||||
Value string `json:"value"`
|
||||
Descriptions []itemdescription `json:"descriptions" `
|
||||
Language string `json:"language"`
|
||||
Value string `json:"value"`
|
||||
Attributes []attribute `json:"attributes" binding:"required"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Photos []photo `json:"photos"`
|
||||
Type string `json:"type" binding:"required"` //video || photo
|
||||
URL string `json:"url" binding:"required"`
|
||||
Questions []question `json:"questions"`
|
||||
Question string `json:"question" `
|
||||
Answer string `json:"answer" `
|
||||
Like uint64 `json:"like" `
|
||||
Dislike uint64 `json:"dislike" `
|
||||
Visits uint64 `json:"visits"`
|
||||
Callbacks []callback `json:"callbacks" binding:"required"`
|
||||
Rating float32 `json:"rating,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Userid string `json:"userID"`
|
||||
Answer string `json:"answer"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
PartnerID []string `json:"partnerID" binding:"required"`
|
||||
|
||||
|
||||
category structure
|
||||
CategoryID uint64 `json:"categoryID" binding:"required"`
|
||||
ParentID uint64 `json:"parentID" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Visible bool `json:"visible" `
|
||||
Priority uint64 `json:"priority" `
|
||||
Icon string `json:"icon"`
|
||||
WideIcon string `json:"wideicon"`
|
||||
ItemsCount uint64
|
||||
CategoriesCount uint64
|
||||
Names []itemname `json:"names"`
|
||||
Language string `json:"language"`
|
||||
Value string `json:"value"`
|
||||
@@ -36,6 +36,9 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
|
||||
|
||||
# Brotli compression (if available)
|
||||
# brotli on;
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
{
|
||||
"name": "api-cache",
|
||||
"urls": [
|
||||
"/api/**"
|
||||
"/api/**",
|
||||
"https://api.dexarmarket.ru:445/**",
|
||||
"https://api.novo.market:444/**"
|
||||
],
|
||||
"cacheConfig": {
|
||||
"maxSize": 100,
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -9580,3 +9580,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,3 +47,4 @@
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||
"name": "Novo Market - Интернет-магазин",
|
||||
"short_name": "Novo",
|
||||
"description": "Novo Market - ваш онлайн магазин качественных товаров с доставкой",
|
||||
@@ -12,34 +11,10 @@
|
||||
"categories": ["shopping", "lifestyle"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
"src": "assets/images/novo-favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
@@ -47,12 +22,6 @@
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
|
||||
@@ -11,34 +11,10 @@
|
||||
"categories": ["shopping", "marketplace"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
"src": "assets/images/dexar-favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
@@ -46,12 +22,6 @@
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
</div>
|
||||
} @else {
|
||||
<app-header></app-header>
|
||||
<main class="main-content">
|
||||
@if (!isHomePage()) {
|
||||
<app-back-button />
|
||||
}
|
||||
<main class="main-content">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-footer></app-footer>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter([])]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -17,14 +17,16 @@ import { TranslateService } from '../../i18n/translate.service';
|
||||
`,
|
||||
styles: [`
|
||||
.dexar-back-btn {
|
||||
position: fixed;
|
||||
top: 76px;
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
left: 20px;
|
||||
z-index: 100;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
padding: 8px 4px;
|
||||
margin-bottom: -40px;
|
||||
width: fit-content;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
svg path {
|
||||
@@ -47,7 +49,7 @@ import { TranslateService } from '../../i18n/translate.service';
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dexar-back-btn {
|
||||
top: 68px;
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
|
||||
svg {
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
<app-region-selector />
|
||||
<app-language-selector />
|
||||
|
||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()" [attr.aria-label]="'header.cart' | translate">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="9" cy="21" r="1"></circle>
|
||||
<circle cx="20" cy="21" r="1"></circle>
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
</a>
|
||||
|
||||
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen">
|
||||
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
@@ -118,7 +118,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen">
|
||||
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="language-selector">
|
||||
<button class="language-button" (click)="toggleDropdown()">
|
||||
<div class="language-selector" role="listbox">
|
||||
<button class="language-button" (click)="toggleDropdown()" (keydown)="onKeyDown($event)" aria-haspopup="listbox" [attr.aria-expanded]="dropdownOpen">
|
||||
<img [src]="languageService.getCurrentLanguage()?.flagSvg"
|
||||
[alt]="languageService.getCurrentLanguage()?.name"
|
||||
class="language-flag">
|
||||
@@ -13,6 +13,8 @@
|
||||
@for (lang of languageService.languages; track lang.code) {
|
||||
<button
|
||||
class="language-option"
|
||||
role="option"
|
||||
[attr.aria-selected]="languageService.currentLanguage() === lang.code"
|
||||
[class.active]="languageService.currentLanguage() === lang.code"
|
||||
[class.disabled]="!lang.enabled"
|
||||
[disabled]="!lang.enabled"
|
||||
|
||||
@@ -44,6 +44,15 @@ export class LanguageSelectorComponent {
|
||||
this.currencyOpen = false;
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
this.dropdownOpen = false;
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.toggleDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onClickOutside(event: Event): void {
|
||||
if (!this.elementRef.nativeElement.contains(event.target)) {
|
||||
|
||||
19
src/app/config/constants.ts
Normal file
19
src/app/config/constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Payment polling
|
||||
export const PAYMENT_POLL_INTERVAL_MS = 5000;
|
||||
export const PAYMENT_MAX_CHECKS = 36;
|
||||
export const PAYMENT_TIMEOUT_CLOSE_MS = 3000;
|
||||
export const PAYMENT_ERROR_CLOSE_MS = 4000;
|
||||
export const LINK_COPIED_DURATION_MS = 2000;
|
||||
|
||||
// Infinite scroll
|
||||
export const SCROLL_THRESHOLD_PX = 1200;
|
||||
export const SCROLL_DEBOUNCE_MS = 100;
|
||||
export const ITEMS_PER_PAGE = 50;
|
||||
|
||||
// Search
|
||||
export const SEARCH_DEBOUNCE_MS = 300;
|
||||
export const SEARCH_MIN_LENGTH = 3;
|
||||
|
||||
// Cache
|
||||
export const CACHE_DURATION_MS = 5 * 60 * 1000;
|
||||
export const CATEGORY_CACHE_DURATION_MS = 2 * 60 * 1000;
|
||||
@@ -2,8 +2,9 @@ import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
||||
import { of } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
import { CACHE_DURATION_MS, CATEGORY_CACHE_DURATION_MS } from '../config/constants';
|
||||
|
||||
const cache = new Map<string, { response: HttpResponse<unknown>, timestamp: number }>();
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 минут
|
||||
|
||||
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
// Кэшируем только GET запросы
|
||||
@@ -11,12 +12,16 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
// Кэшируем только запросы списка категорий (не товары категорий)
|
||||
const shouldCache = req.url.match(/\/category$/) !== null;
|
||||
if (!shouldCache) {
|
||||
// Кэшируем списки категорий, товары категорий и отдельные товары
|
||||
const isCategoryList = /\/category$/.test(req.url);
|
||||
const isCategoryItems = /\/category\/\d+/.test(req.url);
|
||||
const isItem = /\/items\/\d+/.test(req.url);
|
||||
if (!isCategoryList && !isCategoryItems && !isItem) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const ttl = isCategoryList ? CACHE_DURATION_MS : CATEGORY_CACHE_DURATION_MS;
|
||||
|
||||
// Cleanup expired entries before checking
|
||||
cleanupExpiredCache();
|
||||
|
||||
@@ -25,7 +30,7 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
// Проверяем наличие и актуальность кэша
|
||||
if (cachedResponse) {
|
||||
const age = Date.now() - cachedResponse.timestamp;
|
||||
if (age < CACHE_DURATION) {
|
||||
if (age < ttl) {
|
||||
return of(cachedResponse.response.clone());
|
||||
} else {
|
||||
cache.delete(req.url);
|
||||
@@ -53,7 +58,7 @@ export function clearCache(): void {
|
||||
function cleanupExpiredCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [url, data] of cache.entries()) {
|
||||
if (now - data.timestamp >= CACHE_DURATION) {
|
||||
if (now - data.timestamp >= CACHE_DURATION_MS) {
|
||||
cache.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,34 +735,28 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return respond([]);
|
||||
}
|
||||
|
||||
// ── POST /cart (add to cart / create payment)
|
||||
if (url.endsWith('/cart') && req.method === 'POST') {
|
||||
const body = req.body as any;
|
||||
if (body?.amount) {
|
||||
// Payment mock
|
||||
// ── POST /websession/:id (add to cart)
|
||||
if (url.match(/\/websession\/[^/]+$/) && req.method === 'POST') {
|
||||
return respond({
|
||||
sessionId: 'mock-session',
|
||||
Status: true,
|
||||
cart: req.body
|
||||
});
|
||||
}
|
||||
|
||||
// ── POST /websession/:id/qr (create payment QR)
|
||||
if (url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'POST') {
|
||||
return respond({
|
||||
qrId: 'mock-qr-' + Date.now(),
|
||||
qrStatus: 'CREATED',
|
||||
qrStatus: 'NEW',
|
||||
qrExpirationDate: new Date(Date.now() + 180000).toISOString(),
|
||||
payload: 'https://example.com/pay/mock',
|
||||
Payload: 'https://example.com/pay/mock',
|
||||
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment'
|
||||
}, 300);
|
||||
}
|
||||
return respond({ message: 'Added (mock)' });
|
||||
}
|
||||
|
||||
// ── PATCH /cart
|
||||
if (url.endsWith('/cart') && req.method === 'PATCH') {
|
||||
return respond({ message: 'Updated (mock)' });
|
||||
}
|
||||
|
||||
// ── DELETE /cart
|
||||
if (url.endsWith('/cart') && req.method === 'DELETE') {
|
||||
return respond({ message: 'Removed (mock)' });
|
||||
}
|
||||
|
||||
// ── POST /comment
|
||||
if (url.endsWith('/comment') && req.method === 'POST') {
|
||||
// ── POST /items/:id/callback (review)
|
||||
if (url.match(/\/items\/\d+\/callback$/) && req.method === 'POST') {
|
||||
return respond({ message: 'Review submitted (mock)' }, 200);
|
||||
}
|
||||
|
||||
@@ -771,8 +765,8 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return respond({ message: 'Email sent (mock)' }, 200);
|
||||
}
|
||||
|
||||
// ── GET /qr/payment/:id (always return success for testing)
|
||||
if (url.includes('/qr/payment/') && req.method === 'GET') {
|
||||
// ── GET /websession/:id/:qrId (check QR payment status)
|
||||
if (url.match(/\/websession\/[^/]+\/[^/]+$/) && !url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'GET') {
|
||||
return respond({
|
||||
paymentStatus: 'SUCCESS',
|
||||
code: 'SUCCESS',
|
||||
@@ -785,8 +779,7 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
paymentPurpose: '',
|
||||
createDate: new Date().toISOString(),
|
||||
order: 'mock-order',
|
||||
qrExpirationDate: new Date().toISOString(),
|
||||
phoneNumber: ''
|
||||
qrExpirationDate: new Date().toISOString()
|
||||
}, 500);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ItemName } from './item.model';
|
||||
|
||||
export interface Category {
|
||||
categoryID: number;
|
||||
name: string;
|
||||
@@ -5,7 +7,10 @@ export interface Category {
|
||||
icon?: string;
|
||||
wideBanner?: string;
|
||||
itemCount?: number;
|
||||
categoriesCount?: number;
|
||||
priority?: number;
|
||||
names?: ItemName[];
|
||||
translations?: Record<string, CategoryTranslation>;
|
||||
|
||||
// BackOffice API fields
|
||||
id?: string;
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface Question {
|
||||
answer: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
like?: number;
|
||||
dislike?: number;
|
||||
}
|
||||
|
||||
/** Localized name entry from backend */
|
||||
@@ -60,6 +62,16 @@ export interface ItemAttribute {
|
||||
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 {
|
||||
categoryID: number;
|
||||
itemID: number;
|
||||
@@ -95,6 +107,8 @@ export interface Item {
|
||||
subcategoryId?: string;
|
||||
translations?: Record<string, ItemTranslation>;
|
||||
comments?: Comment[];
|
||||
visits?: number;
|
||||
itemDetails?: ItemDetail[];
|
||||
}
|
||||
|
||||
export interface CartItem extends Item {
|
||||
|
||||
@@ -46,12 +46,15 @@
|
||||
|
||||
<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">
|
||||
@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>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
|
||||
.cart-variant {
|
||||
@@ -377,6 +378,18 @@
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cart-colour-swatch {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +497,7 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
|
||||
.cart-variant {
|
||||
@@ -493,6 +507,18 @@
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cart-colour-swatch {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTran
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS, PAYMENT_ERROR_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cart',
|
||||
@@ -55,7 +56,7 @@ export class CartComponent implements OnDestroy {
|
||||
emailSubmitting = signal<boolean>(false);
|
||||
paidItems: CartItem[] = [];
|
||||
|
||||
maxChecks = 36; // 36 checks * 5 seconds = 180 seconds (3 minutes)
|
||||
maxChecks = PAYMENT_MAX_CHECKS;
|
||||
private pollingSubscription?: Subscription;
|
||||
private closeTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -181,51 +182,62 @@ export class CartComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
createPayment(): void {
|
||||
const telegramUsername = this.getTelegramUsername();
|
||||
const userId = this.getUserId();
|
||||
const orderId = this.generateOrderId();
|
||||
const sessionId = this.authService.session()?.sessionId || '';
|
||||
if (!sessionId) {
|
||||
this.paymentStatus.set('timeout');
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentData = {
|
||||
amount: this.totalPrice(),
|
||||
currency: this.langService.currentCurrency(),
|
||||
siteuserID: userId,
|
||||
siteorderID: orderId,
|
||||
redirectUrl: '',
|
||||
telegramUsername: telegramUsername,
|
||||
items: this.items().map((item: CartItem) => ({
|
||||
// First sync cart items to server via websession, then create QR
|
||||
const cartItems = this.items().map((item: CartItem) => ({
|
||||
itemID: item.itemID,
|
||||
quantity: item.quantity,
|
||||
colour: item.colour || '',
|
||||
size: item.size || '',
|
||||
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: () => {
|
||||
this.apiService.createPayment(sessionId).subscribe({
|
||||
next: (response) => {
|
||||
this.paymentId.set(response.qrId);
|
||||
this.qrCodeUrl.set(response.qrUrl);
|
||||
this.paymentUrl.set(response.payload);
|
||||
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();
|
||||
}, 4000);
|
||||
}, PAYMENT_ERROR_CLOSE_MS);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error syncing cart:', err);
|
||||
this.paymentStatus.set('timeout');
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, PAYMENT_ERROR_CLOSE_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startPolling(): void {
|
||||
this.pollingSubscription = interval(5000) // every 5 seconds
|
||||
this.stopPolling();
|
||||
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
|
||||
.pipe(
|
||||
take(this.maxChecks), // maximum 36 checks (3 minutes)
|
||||
switchMap(() => {
|
||||
return this.apiService.checkPaymentStatus(this.paymentId());
|
||||
const sessionId = this.authService.session()?.sessionId || '';
|
||||
return this.apiService.checkPaymentStatus(sessionId, this.paymentId());
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
@@ -245,17 +257,19 @@ export class CartComponent implements OnDestroy {
|
||||
if (this.paymentStatus() === 'waiting') {
|
||||
this.paymentStatus.set('timeout');
|
||||
// Close popup after showing timeout message
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, 3000);
|
||||
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error checking payment status:', err);
|
||||
// Continue checking even on error until time runs out
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, 3000);
|
||||
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -271,34 +285,13 @@ export class CartComponent implements OnDestroy {
|
||||
if (url) {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.linkCopied.set(true);
|
||||
setTimeout(() => this.linkCopied.set(false), 2000);
|
||||
setTimeout(() => this.linkCopied.set(false), LINK_COPIED_DURATION_MS);
|
||||
}).catch(err => {
|
||||
console.error(this.i18n.t('cart.copyError'), err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Mark both fields as touched
|
||||
this.emailTouched.set(true);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@if (!error()) {
|
||||
<div class="items-grid">
|
||||
@for (item of items(); track trackByItemId($index, item)) {
|
||||
<div class="item-card">
|
||||
<div class="item-card" (mouseenter)="onItemHover(item.itemID)">
|
||||
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
||||
<div class="item-image">
|
||||
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
|
||||
@@ -52,19 +52,29 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)">
|
||||
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)" [attr.aria-label]="('category.addToCart' | translate) + ': ' + item.name">
|
||||
{{ 'category.addToCart' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading() && items().length > 0) {
|
||||
<div class="loading-more">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ 'category.loadingMore' | translate }}</p>
|
||||
@for (i of skeletonSlots; track i) {
|
||||
<div class="item-card skeleton-card">
|
||||
<div class="item-link">
|
||||
<div class="item-image skeleton-image"></div>
|
||||
<div class="item-details">
|
||||
<div class="skeleton-line skeleton-title"></div>
|
||||
<div class="skeleton-line skeleton-rating"></div>
|
||||
<div class="skeleton-line skeleton-price"></div>
|
||||
<div class="skeleton-line skeleton-stock"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skeleton-btn"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!hasMore() && items().length > 0) {
|
||||
<div class="no-more">
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 30px;
|
||||
margin-bottom: 40px;
|
||||
width: 100%;
|
||||
@@ -103,8 +103,10 @@
|
||||
|
||||
.item-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
@@ -139,7 +141,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
background: #f0f0f0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@@ -147,7 +149,7 @@
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
transition: transform 0.3s ease;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
@@ -192,6 +194,7 @@
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
@@ -287,11 +290,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -312,24 +310,77 @@
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
// Skeleton loading cards
|
||||
.skeleton-card {
|
||||
pointer-events: none;
|
||||
|
||||
.skeleton-image {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 16px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.skeleton-rating {
|
||||
height: 12px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.skeleton-price {
|
||||
height: 18px;
|
||||
width: 40%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.skeleton-stock {
|
||||
height: 6px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.skeleton-btn {
|
||||
height: 42px;
|
||||
background: linear-gradient(90deg, #5a8a85 25%, #497671 50%, #5a8a85 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 0 0 13px 13px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 1200px) {
|
||||
.items-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.items-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.items-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -353,7 +404,7 @@
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStra
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ApiService, CartService } from '../../services';
|
||||
import { PrefetchService } from '../../services/prefetch.service';
|
||||
import { Item } from '../../models';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS, ITEMS_PER_PAGE } from '../../config/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category',
|
||||
@@ -24,7 +26,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
hasMore = signal(true);
|
||||
|
||||
private skip = 0;
|
||||
private readonly count = 50;
|
||||
private readonly count = ITEMS_PER_PAGE;
|
||||
private isLoadingMore = false;
|
||||
private routeSubscription?: Subscription;
|
||||
private scrollTimeout?: ReturnType<typeof setTimeout>;
|
||||
@@ -32,7 +34,8 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private apiService: ApiService,
|
||||
private cartService: CartService
|
||||
private cartService: CartService,
|
||||
private prefetchService: PrefetchService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -91,12 +94,12 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const bottomPosition = document.documentElement.scrollHeight - 500;
|
||||
const bottomPosition = document.documentElement.scrollHeight - SCROLL_THRESHOLD_PX;
|
||||
|
||||
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore() && !this.isLoadingMore) {
|
||||
this.loadItems();
|
||||
}
|
||||
}, 100);
|
||||
}, SCROLL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
addToCart(itemID: number, event: Event): void {
|
||||
@@ -105,6 +108,11 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
this.cartService.addItem(itemID);
|
||||
}
|
||||
|
||||
onItemHover(itemID: number): void {
|
||||
this.prefetchService.prefetchItem(itemID);
|
||||
}
|
||||
|
||||
readonly skeletonSlots = Array.from({ length: 8 });
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getMainImage = getMainImage;
|
||||
readonly trackByItemId = trackByItemId;
|
||||
|
||||
@@ -48,13 +48,13 @@
|
||||
<a [routerLink]="['/category', cat.categoryID] | langRoute" class="category-card">
|
||||
<div class="category-image">
|
||||
@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 {
|
||||
<div class="category-fallback">{{ cat.name.charAt(0) }}</div>
|
||||
<div class="category-fallback">{{ categoryName(cat).charAt(0) }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="category-info">
|
||||
<h3 class="category-name">{{ cat.name }}</h3>
|
||||
<h3 class="category-name">{{ categoryName(cat) }}</h3>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Subscription } from 'rxjs';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
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({
|
||||
selector: 'app-subcategories',
|
||||
@@ -59,7 +59,7 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
next: (cats) => {
|
||||
this.categories.set(cats);
|
||||
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)
|
||||
const nested = parent?.subcategories || [];
|
||||
@@ -135,4 +135,6 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
|
||||
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
||||
|
||||
categoryName(cat: Category): string { return getTranslatedCategoryName(cat, this.langService.currentLanguage()); }
|
||||
}
|
||||
|
||||
@@ -19,10 +19,23 @@
|
||||
<app-items-carousel />
|
||||
|
||||
@if (loading()) {
|
||||
<div class="novo-loading">
|
||||
<div class="novo-spinner"></div>
|
||||
<p>{{ 'home.loading' | translate }}</p>
|
||||
<section class="novo-categories">
|
||||
<div class="novo-section-header">
|
||||
<div class="skeleton-line" style="height: 32px; width: 200px; margin: 0 auto 12px;"></div>
|
||||
<div class="skeleton-line" style="height: 18px; width: 300px; margin: 0 auto;"></div>
|
||||
</div>
|
||||
<div class="novo-categories-grid">
|
||||
@for (i of skeletonSlots; track i) {
|
||||
<div class="novo-category-card skeleton-card">
|
||||
<div class="novo-category-image skeleton-image"></div>
|
||||
<div class="novo-category-info">
|
||||
<div class="skeleton-line" style="height: 18px; width: 70%;"></div>
|
||||
<div class="skeleton-line" style="height: 18px; width: 20px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
@@ -53,15 +66,15 @@
|
||||
<a [routerLink]="['/category', category.categoryID] | langRoute" class="novo-category-card">
|
||||
<div class="novo-category-image">
|
||||
@if (category.icon) {
|
||||
<img [src]="category.icon" [alt]="category.name" loading="lazy" />
|
||||
<img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" />
|
||||
} @else {
|
||||
<div class="novo-category-placeholder">
|
||||
<span>{{ category.name.charAt(0) }}</span>
|
||||
<span>{{ categoryName(category).charAt(0) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="novo-category-info">
|
||||
<h3>{{ category.name }}</h3>
|
||||
<h3>{{ categoryName(category) }}</h3>
|
||||
<span class="novo-category-arrow">→</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -101,10 +114,20 @@
|
||||
<app-items-carousel />
|
||||
|
||||
@if (loading()) {
|
||||
<div class="dexar-loading">
|
||||
<div class="dexar-spinner"></div>
|
||||
<p>{{ 'home.loadingDexar' | translate }}</p>
|
||||
<section class="dexar-categories">
|
||||
<div class="skeleton-line" style="height: 36px; width: 220px; margin-bottom: 40px;"></div>
|
||||
<div class="dexar-categories-grid">
|
||||
@for (i of skeletonSlots; track i) {
|
||||
<div class="dexar-category-card skeleton-card">
|
||||
<div class="dexar-category-image skeleton-image"></div>
|
||||
<div class="dexar-category-info">
|
||||
<div class="skeleton-line" style="height: 16px; width: 75%;"></div>
|
||||
<div class="skeleton-line" style="height: 12px; width: 40%; margin-top: 4px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
@@ -131,15 +154,15 @@
|
||||
[class.dexar-category-card--wide]="isWideCategory(category.categoryID)">
|
||||
<div class="dexar-category-image">
|
||||
@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) {
|
||||
<img [src]="category.icon" [alt]="category.name" loading="lazy" decoding="async" />
|
||||
<img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" decoding="async" />
|
||||
} @else {
|
||||
<div class="dexar-category-fallback">{{ category.name.charAt(0) }}</div>
|
||||
<div class="dexar-category-fallback">{{ categoryName(category).charAt(0) }}</div>
|
||||
}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -896,3 +896,26 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
// Skeleton loading cards
|
||||
.skeleton-card {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeleton-image {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@@ -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 { ApiService, LanguageService } from '../../services';
|
||||
import { Category } from '../../models';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { getTranslatedCategoryName } from '../../utils/item.utils';
|
||||
import { ItemsCarouselComponent } from '../../components/items-carousel/items-carousel.component';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
@@ -14,13 +15,14 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
styleUrls: ['./home.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class HomeComponent implements OnInit {
|
||||
export class HomeComponent implements OnInit, OnDestroy {
|
||||
brandName = environment.brandFullName;
|
||||
isnovo = environment.theme === 'novo';
|
||||
categories = signal<Category[]>([]);
|
||||
wideCategories = signal<Set<number>>(new Set());
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
readonly skeletonSlots = Array.from({ length: 6 });
|
||||
|
||||
// Memoized computed values for performance
|
||||
topLevelCategories = computed(() => {
|
||||
@@ -56,6 +58,14 @@ export class HomeComponent implements OnInit {
|
||||
this.loadCategories();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pendingImages.forEach(img => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
});
|
||||
this.pendingImages.clear();
|
||||
}
|
||||
|
||||
loadCategories(): void {
|
||||
this.loading.set(true);
|
||||
this.apiService.getCategories().subscribe({
|
||||
@@ -84,13 +94,17 @@ export class HomeComponent implements OnInit {
|
||||
return this.wideCategories().has(categoryID);
|
||||
}
|
||||
|
||||
private pendingImages = new Set<HTMLImageElement>();
|
||||
|
||||
private detectWideImages(categories: Category[]): void {
|
||||
const topLevel = categories.filter(c => c.parentID === 0);
|
||||
topLevel.forEach(cat => {
|
||||
if (!cat.wideBanner) return;
|
||||
|
||||
const img = new Image();
|
||||
this.pendingImages.add(img);
|
||||
img.onload = () => {
|
||||
this.pendingImages.delete(img);
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
if (ratio > 2) {
|
||||
this.wideCategories.update(set => {
|
||||
@@ -100,6 +114,7 @@ export class HomeComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
};
|
||||
img.onerror = () => this.pendingImages.delete(img);
|
||||
img.src = cat.wideBanner;
|
||||
});
|
||||
}
|
||||
@@ -109,6 +124,10 @@ export class HomeComponent implements OnInit {
|
||||
this.router.navigate([`/${lang}/search`]);
|
||||
}
|
||||
|
||||
categoryName(cat: Category): string {
|
||||
return getTranslatedCategoryName(cat, this.langService.currentLanguage());
|
||||
}
|
||||
|
||||
scrollToCatalog(): void {
|
||||
const target = document.getElementById('catalog');
|
||||
if (!target) return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="legal-page">
|
||||
<div class="legal-page">
|
||||
<div class="legal-container">
|
||||
<h1>About the company LLC «INT FIN LOGISTIC»</h1>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<p><strong>Director:</strong> Оганнисян Ашот Рафикович</p>
|
||||
<p><strong>Legal address:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
|
||||
<p><strong>Office in Armenia:</strong><br>0033, Ереван, улица Братьев Орбели, 47</p>
|
||||
<p><strong>Office in Russia:</strong><br>121059, Москва, наб. Тараса Шевченко, 3к2</p>
|
||||
|
||||
<p><strong>Key details:</strong><br>ИНН (RF): 9909697628<br>ИНН (Armenia): 03033502<br>КПП: 770287001<br>ОГРН: 85.110.1408711</p>
|
||||
<p><strong>Banking details:</strong><br>Bank: АО "Райффайзенбанк"<br>Settlement account: 40807810500000002376<br>Correspondent account: 30101810200000000700<br>БИК: 044525700</p>
|
||||
<p><strong>Contact information:</strong><br>Phone (Russia): +7 (926) 459-31-57<br>Phone (Armenia): +374 94 86 18 16<br>Email: info@dexarmarket.ru<br>Website: www.dexarmarket.ru</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="legal-page">
|
||||
<div class="legal-page">
|
||||
<div class="legal-container">
|
||||
<h1>«ИНТ ФИН ЛОГИСТИК» ՍՊԸ ընկերության մասին</h1>
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
<p><strong>Տնօրեն՝</strong> Оганнисян Ашот Рафикович</p>
|
||||
<p><strong>Իրավաբանական հասցե՝</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
|
||||
<p><strong>Գրասենյակ Հայաստանում՝</strong><br>0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</p>
|
||||
<p><strong>Գրասենյակ Ռուսաստանում՝</strong><br>121059, Москва, наб. Тараса Шевченко, 3к2</p>
|
||||
<p><strong>Հիմնական վավերապայմանները՝</strong><br>ՀՍՀ (ՌՄ)՝ 9909697628<br>ՀՍՀ (Հայաստան)՝ 03033502<br>ԿՊՊ՝ 770287001<br>ՕԳՌՆ՝ 85.110.1408711</p>
|
||||
<p><strong>Բանկային վավերապայմանները՝</strong><br>Բանկ՝ АО "Райффайзенбанк"<br>Հաշվարկային հաշիվ՝ 40807810500000002376<br>Թղթակցային հաշիվ՝ 30101810200000000700<br>ԲԻԿ՝ 044525700</p>
|
||||
<p><strong>Կապի տեղեկատվություն՝</strong><br>Հեռախոս (Ռուսաստան)՝ +7 (926) 459-31-57<br>Հեռախոս (Հայաստան)՝ +374 94 86 18 16<br>Էլ. փոստ՝ info@dexarmarket.ru<br>Կայք՝ www.dexarmarket.ru</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="legal-page">
|
||||
<div class="legal-page">
|
||||
<div class="legal-container">
|
||||
<h1>О компании ООО «ИНТ ФИН ЛОГИСТИК»</h1>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<p><strong>Директор:</strong> Оганнисян Ашот Рафикович</p>
|
||||
<p><strong>Юридический адрес:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
|
||||
<p><strong>Офис в Армении:</strong><br>0033, Ереван, улица Братьев Орбели, 47</p>
|
||||
<p><strong>Офис в России:</strong><br>121059, Москва, наб. Тараса Шевченко, 3к2</p>
|
||||
|
||||
<p><strong>Основные реквизиты:</strong><br>ИНН (РФ): 9909697628<br>ИНН (Армения): 03033502<br>КПП: 770287001<br>ОГРН: 85.110.1408711</p>
|
||||
<p><strong>Банковские реквизиты:</strong><br>Банк: АО "Райффайзенбанк"<br>Расчетный счет: 40807810500000002376<br>Корр. счет: 30101810200000000700<br>БИК: 044525700</p>
|
||||
<p><strong>Контактная информация:</strong><br>Телефон (Россия): +7 (926) 459-31-57<br>Телефон (Армения): +374 94 86 18 16<br>Email: info@dexarmarket.ru<br>Сайт: www.dexarmarket.ru</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="legal-page">
|
||||
<div class="legal-page">
|
||||
<div class="legal-container">
|
||||
<h1>Contacts</h1>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<section class="legal-section">
|
||||
<h2>Office Addresses</h2>
|
||||
<p><strong>Office in Armenia:</strong> 0033, Yerevan, Orbeli Brothers Street, 47</p>
|
||||
<p><strong>Office in Russia:</strong> 121059, Moscow, Taras Shevchenko Embankment, 3/2</p>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="legal-section">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="legal-page">
|
||||
<div class="legal-page">
|
||||
<div class="legal-container">
|
||||
<h1>Կապ</h1>
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
<section class="legal-section">
|
||||
<h2>Գրասենյակների հասցեներ</h2>
|
||||
<p><strong>Գրասենյակ Հայաստանում՝</strong> 0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</p>
|
||||
<p><strong>Գրասենյակ Ռուսաստանում՝</strong> 121059, Մոսկվա, Տարաս Շևչենկոի փողոց, 3կ2</p>
|
||||
</section>
|
||||
|
||||
<section class="legal-section">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="legal-page">
|
||||
<div class="legal-page">
|
||||
<div class="legal-container">
|
||||
<h1>Контакты</h1>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<section class="legal-section">
|
||||
<h2>Адреса офисов</h2>
|
||||
<p><strong>Офис в Армении:</strong> 0033, Ереван, улица Братьев Орбели, 47</p>
|
||||
<p><strong>Офис в России:</strong> 121059, Москва, наб. Тараса Шевченко, 3к2</p>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="legal-section">
|
||||
|
||||
@@ -82,12 +82,12 @@
|
||||
<div class="novo-price-block">
|
||||
@if (item()!.discount > 0) {
|
||||
<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>
|
||||
</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 {
|
||||
<div class="current-price">{{ item()!.price }} {{ item()!.currency }}</div>
|
||||
<div class="current-price">{{ effectivePrice() }} {{ effectiveCurrency() }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -97,23 +97,37 @@
|
||||
<span class="dot"></span>
|
||||
{{ getStockLabel() }}
|
||||
</div>
|
||||
@if (item()!.quantity != null) {
|
||||
<span class="stock-qty">({{ item()!.quantity }} шт.)</span>
|
||||
@if (effectiveRemaining() != null) {
|
||||
<span class="stock-qty">({{ effectiveRemaining() }} шт.)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (item()!.colour || item()!.size) {
|
||||
@if (availableColours().length || availableSizes().length || item()!.colour || (item()!.size && item()!.size!.toLowerCase() !== 'default')) {
|
||||
<div class="novo-variants">
|
||||
@if (item()!.colour) {
|
||||
@if (availableColours().length) {
|
||||
<div class="variant-group">
|
||||
<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>
|
||||
}
|
||||
@if (item()!.size) {
|
||||
@if (availableSizes().length) {
|
||||
<div class="variant-group">
|
||||
<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>
|
||||
@@ -347,12 +361,12 @@
|
||||
<div class="dx-price-block">
|
||||
@if (item()!.discount > 0) {
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
<div class="dx-current-price">
|
||||
{{ item()!.discount > 0 ? (getDiscountedPrice() | number:'1.2-2') : item()!.price }} {{ item()!.currency }}
|
||||
{{ item()!.discount > 0 ? (getDiscountedPrice() | number:'1.2-2') : effectivePrice() }} {{ effectiveCurrency() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -362,23 +376,37 @@
|
||||
<span class="dx-stock-dot"></span>
|
||||
{{ getStockLabel() }}
|
||||
</span>
|
||||
@if (item()!.quantity != null) {
|
||||
<span class="dx-stock-qty">({{ item()!.quantity }} шт.)</span>
|
||||
@if (effectiveRemaining() != null) {
|
||||
<span class="dx-stock-qty">({{ effectiveRemaining() }} шт.)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (item()!.colour || item()!.size) {
|
||||
@if (availableColours().length || availableSizes().length || item()!.colour || (item()!.size && item()!.size!.toLowerCase() !== 'default')) {
|
||||
<div class="dx-variants">
|
||||
@if (item()!.colour) {
|
||||
@if (availableColours().length) {
|
||||
<div class="variant-group">
|
||||
<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>
|
||||
}
|
||||
@if (item()!.size) {
|
||||
@if (availableSizes().length) {
|
||||
<div class="variant-group">
|
||||
<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>
|
||||
@@ -405,7 +433,8 @@
|
||||
</button>
|
||||
|
||||
<div class="dx-description">
|
||||
@if (getSimpleDescription()) {
|
||||
<!-- @if (getSimpleDescription()) { -->
|
||||
@if (false) {
|
||||
<p class="dx-simple-desc">{{ getSimpleDescription() }}</p>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// ========== DEXAR ITEM DETAIL - Redesigned 2026 ==========
|
||||
@use 'sass:color';
|
||||
|
||||
// ========== DEXAR ITEM DETAIL - Redesigned 2026 ==========
|
||||
$dx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
$dx-dark: #1e3c38;
|
||||
$dx-primary: #497671;
|
||||
@@ -50,7 +52,7 @@ $dx-card-bg: #f5f3f9;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: darken($dx-primary, 8%);
|
||||
background: color.adjust($dx-primary, $lightness: -8%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
@@ -281,7 +283,7 @@ $dx-card-bg: #f5f3f9;
|
||||
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
|
||||
|
||||
&:hover {
|
||||
background: darken($dx-primary, 8%);
|
||||
background: color.adjust($dx-primary, $lightness: -8%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(73, 118, 113, 0.3);
|
||||
}
|
||||
@@ -294,7 +296,7 @@ $dx-card-bg: #f5f3f9;
|
||||
// Variant chips (colour/size) — shared between dexar and novo
|
||||
.dx-variants, .novo-variants {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
@@ -302,6 +304,7 @@ $dx-card-bg: #f5f3f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.variant-label {
|
||||
@@ -320,6 +323,37 @@ $dx-card-bg: #f5f3f9;
|
||||
border: 1.5px solid $dx-border;
|
||||
background: rgba(73, 118, 113, 0.06);
|
||||
color: $dx-primary;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(73, 118, 113, 0.12);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: $dx-primary;
|
||||
background: rgba(73, 118, 113, 0.18);
|
||||
box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.colour-swatch {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid $dx-border;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: $dx-primary;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: $dx-primary;
|
||||
box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,7 +526,7 @@ $dx-card-bg: #f5f3f9;
|
||||
justify-content: center;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: darken($dx-primary, 8%);
|
||||
background: color.adjust($dx-primary, $lightness: -8%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
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 { Subscription } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
@@ -27,6 +28,55 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
||||
error = signal<string | null>(null);
|
||||
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 = {
|
||||
rating: 0,
|
||||
comment: '',
|
||||
@@ -42,6 +92,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
private seoService = inject(SeoService);
|
||||
private i18n = inject(TranslateService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -73,6 +124,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
||||
this.apiService.getItem(itemID).subscribe({
|
||||
next: (item) => {
|
||||
this.item.set(item);
|
||||
this.initVariantSelection(item);
|
||||
this.seoService.setItemMeta(item);
|
||||
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 {
|
||||
this.selectedPhotoIndex.set(index);
|
||||
}
|
||||
@@ -91,14 +170,21 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
||||
addToCart(): void {
|
||||
const currentItem = this.item();
|
||||
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 {
|
||||
const currentItem = this.item();
|
||||
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
|
||||
@@ -113,8 +199,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
||||
getSimpleDescription(): string {
|
||||
const currentItem = this.item();
|
||||
if (!currentItem) return '';
|
||||
const lang = this.languageService.currentLanguage();
|
||||
return getTranslatedField(currentItem, 'simpleDescription', lang);
|
||||
return currentItem.simpleDescription || currentItem.description || '';
|
||||
}
|
||||
|
||||
hasDescriptionFields(): boolean {
|
||||
@@ -207,8 +292,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
||||
itemID: currentItem.itemID,
|
||||
rating: this.newReview.rating,
|
||||
comment: this.newReview.comment.trim(),
|
||||
username: this.newReview.anonymous ? null : this.getUserDisplayName(),
|
||||
userId: this.telegramService.getUserId(),
|
||||
sessionID: this.authService.session()?.sessionId || '',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
@if (items().length > 0) {
|
||||
<div class="items-grid">
|
||||
@for (item of items(); track trackByItemId($index, item)) {
|
||||
<div class="item-card">
|
||||
<div class="item-card" (mouseenter)="onItemHover(item.itemID)">
|
||||
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
||||
<div class="item-image">
|
||||
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
|
||||
@@ -105,7 +105,7 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)">
|
||||
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)" [attr.aria-label]="('search.addToCart' | translate) + ': ' + item.name">
|
||||
{{ 'search.addToCart' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -113,10 +113,20 @@
|
||||
</div>
|
||||
|
||||
@if (loading() && items().length > 0) {
|
||||
<div class="loading-more">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ 'search.loadingMore' | translate }}</p>
|
||||
@for (i of skeletonSlots; track i) {
|
||||
<div class="item-card skeleton-card">
|
||||
<div class="item-link">
|
||||
<div class="item-image skeleton-image"></div>
|
||||
<div class="item-details">
|
||||
<div class="skeleton-line skeleton-title"></div>
|
||||
<div class="skeleton-line skeleton-rating"></div>
|
||||
<div class="skeleton-line skeleton-price"></div>
|
||||
<div class="skeleton-line skeleton-stock"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skeleton-btn"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!hasMore() && items().length > 0) {
|
||||
|
||||
@@ -344,6 +344,59 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Skeleton loading cards
|
||||
.skeleton-card {
|
||||
pointer-events: none;
|
||||
|
||||
.skeleton-image {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 16px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.skeleton-rating {
|
||||
height: 12px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.skeleton-price {
|
||||
height: 18px;
|
||||
width: 40%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.skeleton-stock {
|
||||
height: 6px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.skeleton-btn {
|
||||
height: 42px;
|
||||
background: linear-gradient(90deg, #5a8a85 25%, #497671 50%, #5a8a85 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 0 0 13px 13px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-header h1 {
|
||||
font-size: 1.5rem;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DecimalPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService, CartService } from '../../services';
|
||||
import { PrefetchService } from '../../services/prefetch.service';
|
||||
import { Item } from '../../models';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
@@ -11,6 +12,7 @@ import { LanguageService } from '../../services/language.service';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
import { SEARCH_DEBOUNCE_MS, ITEMS_PER_PAGE, SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS } from '../../config/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-search',
|
||||
@@ -28,7 +30,7 @@ export class SearchComponent implements OnDestroy {
|
||||
totalResults = signal<number>(0);
|
||||
|
||||
private skip = 0;
|
||||
private readonly count = 50;
|
||||
private readonly count = ITEMS_PER_PAGE;
|
||||
private isLoadingMore = false;
|
||||
private searchSubject = new Subject<string>();
|
||||
private searchSubscription: Subscription;
|
||||
@@ -36,11 +38,12 @@ export class SearchComponent implements OnDestroy {
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private cartService: CartService
|
||||
private cartService: CartService,
|
||||
private prefetchService: PrefetchService
|
||||
) {
|
||||
this.searchSubscription = this.searchSubject
|
||||
.pipe(
|
||||
debounceTime(300),
|
||||
debounceTime(SEARCH_DEBOUNCE_MS),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe(query => {
|
||||
@@ -64,7 +67,7 @@ export class SearchComponent implements OnDestroy {
|
||||
performSearch(query: string): void {
|
||||
if (!query.trim()) {
|
||||
this.items.set([]);
|
||||
this.hasMore.set(true);
|
||||
this.hasMore.set(false);
|
||||
this.totalResults.set(0);
|
||||
return;
|
||||
}
|
||||
@@ -120,12 +123,12 @@ export class SearchComponent implements OnDestroy {
|
||||
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const bottomPosition = document.documentElement.scrollHeight - 500;
|
||||
const bottomPosition = document.documentElement.scrollHeight - SCROLL_THRESHOLD_PX;
|
||||
|
||||
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore()) {
|
||||
this.loadResults();
|
||||
}
|
||||
}, 100);
|
||||
}, SCROLL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
addToCart(itemID: number, event: Event): void {
|
||||
@@ -134,6 +137,11 @@ export class SearchComponent implements OnDestroy {
|
||||
this.cartService.addItem(itemID);
|
||||
}
|
||||
|
||||
onItemHover(itemID: number): void {
|
||||
this.prefetchService.prefetchItem(itemID);
|
||||
}
|
||||
|
||||
readonly skeletonSlots = Array.from({ length: 8 });
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getMainImage = getMainImage;
|
||||
readonly trackByItemId = trackByItemId;
|
||||
|
||||
@@ -7,19 +7,30 @@ import { LanguageService } from '../services/language.service';
|
||||
})
|
||||
export class LangRoutePipe implements PipeTransform {
|
||||
private langService = inject(LanguageService);
|
||||
private lastLang = '';
|
||||
private lastInput: unknown = null;
|
||||
private lastResult: string | (string | number)[] = '';
|
||||
|
||||
transform(value: string | (string | number)[]): string | (string | number)[] {
|
||||
const lang = this.langService.currentLanguage();
|
||||
|
||||
// Short-circuit if nothing changed
|
||||
if (lang === this.lastLang && value === this.lastInput) {
|
||||
return this.lastResult;
|
||||
}
|
||||
|
||||
this.lastLang = lang;
|
||||
this.lastInput = value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value === '/' ? `/${lang}` : `/${lang}${value}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
this.lastResult = value === '/' ? `/${lang}` : `/${lang}${value}`;
|
||||
} else if (Array.isArray(value) && value.length > 0) {
|
||||
const [first, ...rest] = value;
|
||||
return [`/${lang}${first}`, ...rest];
|
||||
this.lastResult = [`/${lang}${first}`, ...rest];
|
||||
} else {
|
||||
this.lastResult = value;
|
||||
}
|
||||
|
||||
return value;
|
||||
return this.lastResult;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Observable, timer } from 'rxjs';
|
||||
import { map, retry } from 'rxjs/operators';
|
||||
import { Category, Item, Subcategory } from '../models';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@@ -11,8 +11,34 @@ import { environment } from '../../environments/environment';
|
||||
export class ApiService {
|
||||
private readonly baseUrl = environment.apiUrl;
|
||||
|
||||
private readonly retryConfig = {
|
||||
count: 2,
|
||||
delay: (error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
|
||||
};
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/** 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
|
||||
* legacy marketplace format and the new backOffice API format.
|
||||
@@ -21,6 +47,26 @@ export class ApiService {
|
||||
const { partnerID, ...rest } = raw;
|
||||
const item: Item = { ...rest };
|
||||
|
||||
// Extract price/currency/remaining/colour/size from itemDetails[]
|
||||
// Note: Go struct tag is "itemdetails" but actual API may send "itemDetails"
|
||||
const details = raw.itemDetails || raw.itemdetails;
|
||||
if (details && Array.isArray(details) && details.length > 0) {
|
||||
const detail = details[0];
|
||||
item.itemDetails = details.map((d: any) => ({
|
||||
...d,
|
||||
colour: this.normalizeColor(d.colour || d.color || ''),
|
||||
color: undefined,
|
||||
}));
|
||||
if (item.price == null || item.price === 0) item.price = detail.price;
|
||||
if (!item.currency) item.currency = detail.currency;
|
||||
if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || '');
|
||||
if (!item.size) item.size = detail.size || '';
|
||||
// Use remaining from detail for stock level
|
||||
if (raw.remaining == null && detail.remaining != null) {
|
||||
(raw as any).remaining = detail.remaining;
|
||||
}
|
||||
}
|
||||
|
||||
// Map backOffice string id → legacy numeric itemID
|
||||
if (raw.id != null && raw.itemID == null) {
|
||||
item.id = String(raw.id);
|
||||
@@ -32,13 +78,16 @@ export class ApiService {
|
||||
item.photos = raw.imgs.map((url: string) => ({ url }));
|
||||
}
|
||||
// Normalize photo type: API sends type='video'|'photo', template checks .video
|
||||
// Also resolve relative URLs (e.g. ./images/x.webp) against API base
|
||||
if (item.photos) {
|
||||
item.photos = item.photos.map((p: any) => ({
|
||||
...p,
|
||||
url: this.resolveImageUrl(p.url),
|
||||
video: p.video || (p.type === 'video' ? p.url : undefined),
|
||||
}));
|
||||
}
|
||||
item.imgs = raw.imgs || 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
|
||||
if (Array.isArray(raw.description)) {
|
||||
@@ -49,31 +98,31 @@ export class ApiService {
|
||||
}
|
||||
|
||||
// Map backend names[] → translations (multi-lang name support)
|
||||
// Note: API has typo "valuue" in some responses, handle both
|
||||
if (raw.names && Array.isArray(raw.names)) {
|
||||
item.names = raw.names;
|
||||
if (!item.translations) item.translations = {};
|
||||
for (const entry of raw.names) {
|
||||
if (!item.translations[entry.language]) item.translations[entry.language] = {};
|
||||
item.translations[entry.language].name = entry.value;
|
||||
const lang = this.normalizeLang(entry.language);
|
||||
const val = entry.value || entry.valuue || '';
|
||||
if (val) {
|
||||
if (!item.translations[lang]) item.translations[lang] = {};
|
||||
item.translations[lang].name = val;
|
||||
}
|
||||
}
|
||||
|
||||
// Map backend descriptions[] → translations (multi-lang descriptions)
|
||||
if (raw.descriptions && Array.isArray(raw.descriptions)) {
|
||||
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;
|
||||
// Fallback: if top-level name is missing, use first available translation
|
||||
if (!item.name && raw.names.length > 0) {
|
||||
const ruName = raw.names.find((n: any) => n.language === 'RU' || n.language === 'ru');
|
||||
item.name = ruName?.value || ruName?.valuue || raw.names[0].value || raw.names[0].valuue || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve attributes from backend
|
||||
item.attributes = raw.attributes || [];
|
||||
|
||||
// Preserve colour & size
|
||||
item.colour = raw.colour || '';
|
||||
item.size = raw.size || '';
|
||||
// Preserve colour & size (only if not already set from itemDetails)
|
||||
if (!item.colour) item.colour = this.normalizeColor(raw.colour || '');
|
||||
if (!item.size) item.size = raw.size || '';
|
||||
|
||||
// Map backOffice comments → legacy callbacks
|
||||
if (raw.comments && (!raw.callbacks || raw.callbacks.length === 0)) {
|
||||
@@ -102,8 +151,12 @@ export class ApiService {
|
||||
item.rating = item.rating || 0;
|
||||
|
||||
// Defaults
|
||||
item.name = item.name || '';
|
||||
item.price = item.price ?? 0;
|
||||
item.discount = item.discount || 0;
|
||||
item.remainings = item.remainings || (raw.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')
|
||||
: 'high');
|
||||
item.currency = item.currency || 'RUB';
|
||||
@@ -115,6 +168,16 @@ export class ApiService {
|
||||
item.translations = item.translations || raw.translations || {};
|
||||
item.visible = raw.visible ?? true;
|
||||
item.priority = raw.priority ?? 0;
|
||||
item.visits = raw.visits ?? 0;
|
||||
|
||||
// Map question like/dislike → upvotes/downvotes
|
||||
if (item.questions) {
|
||||
item.questions = item.questions.map((q: any) => ({
|
||||
...q,
|
||||
upvotes: q.upvotes ?? q.like ?? 0,
|
||||
downvotes: q.downvotes ?? q.dislike ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
@@ -144,9 +207,41 @@ export class ApiService {
|
||||
}
|
||||
cat.img = raw.img || raw.icon;
|
||||
|
||||
// Resolve relative icon/image URLs
|
||||
if (cat.icon) cat.icon = this.resolveImageUrl(cat.icon);
|
||||
if (cat.img) cat.img = this.resolveImageUrl(cat.img);
|
||||
|
||||
// Map backend wideicon → wideBanner
|
||||
if (raw.wideicon && !cat.wideBanner) {
|
||||
cat.wideBanner = raw.wideicon;
|
||||
}
|
||||
|
||||
cat.parentID = raw.parentID ?? 0;
|
||||
cat.visible = raw.visible ?? true;
|
||||
cat.priority = raw.priority ?? 0;
|
||||
cat.itemCount = raw.itemCount ?? raw.ItemsCount ?? 0;
|
||||
cat.categoriesCount = raw.categoriesCount ?? raw.CategoriesCount ?? 0;
|
||||
|
||||
// Map backend names[] → translations (multi-lang name support)
|
||||
// Note: API has typo "valuue" in some responses, handle both
|
||||
if (raw.names && Array.isArray(raw.names)) {
|
||||
cat.names = raw.names;
|
||||
cat.translations = cat.translations || {};
|
||||
for (const entry of raw.names) {
|
||||
const lang = this.normalizeLang(entry.language);
|
||||
const val = entry.value || entry.valuue || '';
|
||||
if (val) {
|
||||
if (!cat.translations[lang]) cat.translations[lang] = {};
|
||||
cat.translations[lang].name = val;
|
||||
}
|
||||
}
|
||||
// Fallback: if top-level name is missing, use first available translation
|
||||
if (!cat.name && raw.names.length > 0) {
|
||||
const ruName = raw.names.find((n: any) => n.language === 'RU' || n.language === 'ru');
|
||||
cat.name = ruName?.value || ruName?.valuue || raw.names[0].value || raw.names[0].valuue || '';
|
||||
}
|
||||
}
|
||||
cat.name = cat.name || '';
|
||||
|
||||
if (raw.subcategories && Array.isArray(raw.subcategories)) {
|
||||
cat.subcategories = raw.subcategories;
|
||||
@@ -168,7 +263,7 @@ export class ApiService {
|
||||
|
||||
getCategories(): Observable<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[]> {
|
||||
@@ -176,21 +271,48 @@ export class ApiService {
|
||||
.set('count', count.toString())
|
||||
.set('skip', skip.toString());
|
||||
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> {
|
||||
return this.http.get<any>(`${this.baseUrl}/item/${itemID}`)
|
||||
.pipe(map(item => this.normalizeItem(item)));
|
||||
return this.http.get<any>(`${this.baseUrl}/items/${itemID}`)
|
||||
.pipe(retry(this.retryConfig), map(item => this.normalizeItem(item)));
|
||||
}
|
||||
|
||||
searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> {
|
||||
const params = new HttpParams()
|
||||
searchItems(
|
||||
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('count', count.toString())
|
||||
.set('skip', skip.toString());
|
||||
if (options?.categoryIDs?.length) {
|
||||
params = params.set('categoryIDs', options.categoryIDs.join(','));
|
||||
}
|
||||
if (options?.minPrice != null) {
|
||||
params = params.set('minPrice', options.minPrice.toString());
|
||||
}
|
||||
if (options?.maxPrice != null) {
|
||||
params = params.set('maxPrice', options.maxPrice.toString());
|
||||
}
|
||||
if (options?.tag) {
|
||||
params = params.set('tag', options.tag);
|
||||
}
|
||||
if (options?.sort) {
|
||||
params = params.set('sort', options.sort);
|
||||
}
|
||||
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
|
||||
.pipe(
|
||||
retry(this.retryConfig),
|
||||
map(response => ({
|
||||
items: this.normalizeItems(response?.items || []),
|
||||
total: response?.total || 0
|
||||
@@ -198,21 +320,9 @@ export class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
addToCart(itemID: number, quantity: number = 1): Observable<{ message: string }> {
|
||||
return this.http.post<{ message: string }>(`${this.baseUrl}/cart`, { itemID, quantity });
|
||||
}
|
||||
|
||||
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)));
|
||||
// Cart operations — spec uses websession-based paths
|
||||
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);
|
||||
}
|
||||
|
||||
// Review submission
|
||||
@@ -220,39 +330,42 @@ export class ApiService {
|
||||
itemID: number;
|
||||
rating: number;
|
||||
comment: string;
|
||||
username: string | null;
|
||||
userId: number | null;
|
||||
sessionID: string;
|
||||
timestamp: 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
|
||||
createPayment(paymentData: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
siteuserID: string;
|
||||
siteorderID: string;
|
||||
redirectUrl: string;
|
||||
telegramUsername: string;
|
||||
items: Array<{ itemID: number; price: number; name: string }>;
|
||||
}): Observable<{
|
||||
// Question submission — spec path has typo "questiion"
|
||||
submitQuestion(questionData: {
|
||||
itemID: number;
|
||||
question: string;
|
||||
sessionID: string;
|
||||
timestamp: string;
|
||||
}): Observable<{ message: string }> {
|
||||
const { itemID, ...body } = questionData;
|
||||
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/questiion`, body);
|
||||
}
|
||||
|
||||
// Payment - SBP Integration via websession QR
|
||||
createPayment(sessionId: string): Observable<{
|
||||
qrId: string;
|
||||
qrStatus: string;
|
||||
qrExpirationDate: string;
|
||||
payload: string;
|
||||
Payload: string;
|
||||
qrUrl: string;
|
||||
}> {
|
||||
return this.http.post<{
|
||||
qrId: string;
|
||||
qrStatus: string;
|
||||
qrExpirationDate: string;
|
||||
payload: string;
|
||||
Payload: string;
|
||||
qrUrl: string;
|
||||
}>(`${this.baseUrl}/cart`, paymentData);
|
||||
}>(`${this.baseUrl}/websession/${sessionId}/qr`, {});
|
||||
}
|
||||
|
||||
checkPaymentStatus(qrId: string): Observable<{
|
||||
checkPaymentStatus(sessionId: string, qrId: string): Observable<{
|
||||
additionalInfo: string;
|
||||
paymentPurpose: string;
|
||||
amount: number;
|
||||
@@ -265,7 +378,6 @@ export class ApiService {
|
||||
transactionDate: string;
|
||||
transactionId: number;
|
||||
qrExpirationDate: string;
|
||||
phoneNumber: string;
|
||||
}> {
|
||||
return this.http.get<{
|
||||
additionalInfo: string;
|
||||
@@ -280,8 +392,7 @@ export class ApiService {
|
||||
transactionDate: string;
|
||||
transactionId: number;
|
||||
qrExpirationDate: string;
|
||||
phoneNumber: string;
|
||||
}>(`${this.baseUrl}/qr/payment/${qrId}`);
|
||||
}>(`${this.baseUrl}/websession/${sessionId}/${qrId}`);
|
||||
}
|
||||
|
||||
submitPurchaseEmail(emailData: {
|
||||
@@ -297,7 +408,7 @@ export class ApiService {
|
||||
if (categoryID) {
|
||||
params = params.set('category', categoryID.toString());
|
||||
}
|
||||
return this.http.get<any[]>(`${this.baseUrl}/randomitems`, { params })
|
||||
.pipe(map(items => this.normalizeItems(items)));
|
||||
return this.http.get<any[]>(`${this.baseUrl}/items/randomitems`, { params })
|
||||
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export class AuthService {
|
||||
|
||||
/** Generate the Telegram login URL for bot-based auth */
|
||||
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`);
|
||||
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export class CartService {
|
||||
private cartItems = signal<CartItem[]>([]);
|
||||
private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp;
|
||||
private addingItems = new Set<number>();
|
||||
private initialized = false;
|
||||
|
||||
items = this.cartItems.asReadonly();
|
||||
itemCount = computed(() => {
|
||||
@@ -31,10 +32,12 @@ export class CartService {
|
||||
constructor(private apiService: ApiService) {
|
||||
this.loadCart();
|
||||
|
||||
// Auto-save whenever cart changes
|
||||
// Auto-save whenever cart changes (skip the initial empty state)
|
||||
effect(() => {
|
||||
const items = this.cartItems();
|
||||
if (this.initialized) {
|
||||
this.saveToStorage(items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,9 +70,11 @@ export class CartService {
|
||||
// No data in CloudStorage, try localStorage
|
||||
this.loadFromLocalStorage();
|
||||
}
|
||||
this.initialized = true;
|
||||
});
|
||||
} else {
|
||||
this.loadFromLocalStorage();
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +103,7 @@ export class CartService {
|
||||
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
|
||||
if (this.addingItems.has(itemID)) return;
|
||||
|
||||
@@ -113,7 +118,14 @@ export class CartService {
|
||||
this.addingItems.add(itemID);
|
||||
this.apiService.getItem(itemID).subscribe({
|
||||
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.addingItems.delete(itemID);
|
||||
},
|
||||
|
||||
@@ -24,8 +24,8 @@ export class LanguageService {
|
||||
|
||||
languages: Language[] = [
|
||||
{ code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true },
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: true },
|
||||
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: true }
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: false },
|
||||
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: false }
|
||||
];
|
||||
|
||||
currencies: Currency[] = [
|
||||
|
||||
15
src/app/services/prefetch.service.ts
Normal file
15
src/app/services/prefetch.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable, inject, DOCUMENT } from '@angular/core';
|
||||
import { Meta, Title } from '@angular/platform-browser';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { Item } from '../models';
|
||||
@@ -10,6 +10,7 @@ import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
|
||||
export class SeoService {
|
||||
private meta = inject(Meta);
|
||||
private title = inject(Title);
|
||||
private doc = inject(DOCUMENT);
|
||||
|
||||
private readonly siteUrl = `https://${environment.domain}`;
|
||||
private readonly siteName = environment.brandFullName;
|
||||
@@ -18,13 +19,14 @@ export class SeoService {
|
||||
* Set Open Graph & Twitter Card meta tags for a product/item page.
|
||||
*/
|
||||
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 itemUrl = `${this.siteUrl}/item/${item.itemID}`;
|
||||
const description = this.truncate(this.stripHtml(item.description), 160);
|
||||
const titleText = `${item.name} — ${this.siteName}`;
|
||||
const description = this.truncate(this.stripHtml(item.description || ''), 160);
|
||||
const titleText = `${item.name || 'Product'} — ${this.siteName}`;
|
||||
|
||||
this.title.setTitle(titleText);
|
||||
this.setCanonical(itemUrl);
|
||||
|
||||
this.setOrUpdate([
|
||||
// Open Graph
|
||||
@@ -81,6 +83,7 @@ export class SeoService {
|
||||
// Remove product-specific tags
|
||||
this.meta.removeTag("property='product:price:amount'");
|
||||
this.meta.removeTag("property='product:price:currency'");
|
||||
this.removeCanonical();
|
||||
}
|
||||
|
||||
private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void {
|
||||
@@ -114,4 +117,19 @@ export class SeoService {
|
||||
if (!text || text.length <= maxLength) return text || '';
|
||||
return text.substring(0, maxLength - 1) + '…';
|
||||
}
|
||||
|
||||
private setCanonical(url: string): void {
|
||||
this.removeCanonical();
|
||||
const link = this.doc.createElement('link');
|
||||
link.setAttribute('rel', 'canonical');
|
||||
link.setAttribute('href', url);
|
||||
this.doc.head.appendChild(link);
|
||||
}
|
||||
|
||||
private removeCanonical(): void {
|
||||
const existing = this.doc.head.querySelector('link[rel="canonical"]');
|
||||
if (existing) {
|
||||
this.doc.head.removeChild(existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Item } from '../models';
|
||||
import { Category } from '../models/category.model';
|
||||
|
||||
export function getDiscountedPrice(item: Item): number {
|
||||
return item.price * (1 - (item.discount || 0) / 100);
|
||||
@@ -69,24 +70,37 @@ export function getTranslatedField(
|
||||
field: 'name' | 'simpleDescription',
|
||||
lang: string
|
||||
): string {
|
||||
// 1. Check translations map (backOffice format)
|
||||
// 1. Check translations map (already normalized to frontend codes)
|
||||
const translation = item.translations?.[lang];
|
||||
if (translation && translation[field]) {
|
||||
return translation[field]!;
|
||||
}
|
||||
|
||||
// 2. Check names[]/descriptions[] arrays (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) {
|
||||
const entry = item.names.find(n => n.language === lang);
|
||||
if (entry) return entry.value;
|
||||
const entry = item.names.find(n => n.language === lang || n.language === lang.toUpperCase() || (lang === 'hy' && n.language === 'AM'));
|
||||
const val = entry?.value || (entry as any)?.valuue || '';
|
||||
if (val) return val;
|
||||
}
|
||||
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
|
||||
if (field === 'name') return item.name;
|
||||
if (field === 'simpleDescription') return item.simpleDescription || item.description || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translated category name for the current language.
|
||||
*/
|
||||
export function getTranslatedCategoryName(cat: Category, lang: string): string {
|
||||
const translation = cat.translations?.[lang];
|
||||
if (translation?.name) return translation.name;
|
||||
|
||||
if (cat.names?.length) {
|
||||
const entry = cat.names.find(n => n.language === lang || n.language === lang.toUpperCase() || (lang === 'hy' && n.language === 'AM'));
|
||||
const val = entry?.value || (entry as any)?.valuue || '';
|
||||
if (val) return val;
|
||||
}
|
||||
|
||||
return cat.name || '';
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const environment = {
|
||||
supportEmail: 'info@dexarmarket.ru',
|
||||
domain: 'dexarmarket.ru',
|
||||
telegram: '@dexarmarket',
|
||||
telegramBot: 'dexarmarket_bot',
|
||||
telegramBot: 'DexarSupport_bot',
|
||||
phones: {
|
||||
russia: '+7 (926) 459-31-57',
|
||||
armenia: '+374 94 86 18 16'
|
||||
|
||||
@@ -11,7 +11,7 @@ export const environment = {
|
||||
supportEmail: 'info@dexarmarket.ru',
|
||||
domain: 'dexarmarket.ru',
|
||||
telegram: '@dexarmarket',
|
||||
telegramBot: 'dexarmarket_bot',
|
||||
telegramBot: 'DexarSupport_bot',
|
||||
phones: {
|
||||
russia: '+7 (926) 459-31-57',
|
||||
armenia: '+374 94 86 18 16'
|
||||
|
||||
4
start
4
start
@@ -1,2 +1,2 @@
|
||||
pm2 start "ng serve --configuration=novo --host 127.0.0.1 --port 4000" --name novo-market
|
||||
pm2 start "ng serve --host 127.0.0.1 --port 3000" --name dexar-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 4001" --name dexar-market
|
||||
|
||||
Reference in New Issue
Block a user