fixes
This commit is contained in:
@@ -1,11 +1,748 @@
|
|||||||
bro we need to do changes, that client required
|
General Information
|
||||||
1. we need to add location logic
|
Information exchange with the SBP server is realized via RESTful API. All requests to the server must be executed via HTTPS using GET||POST||PUT||DELETE requests to the given ROOT address. Body of requests must be in JSON format. All not public requests must be signed by the client and the public key must be sent to the server for client identification and sign checking.
|
||||||
1.1 the catalogs will come or for global or for exact region
|
Header:
|
||||||
1.2 need to add a place where the user can choose his region like city if choosed moscow the country is set russian
|
“Authorization”: {JSON WITH KEY AND PARTNERID}
|
||||||
1.3 can we try to understand what country is user logged or whach city by global ip and set it?
|
“X-Region” : Moscow | Yerevan | ST. Petersburg
|
||||||
2. we need to add somekind of user login logic
|
“X-Language” : RU | AM | EN
|
||||||
2.1 user can add to cart, look the items and etc without logged in, but when he is going to buy/pay ->
|
“WebSessionID” : f02fe5d6-c6ae-4b2e-9b4d-687534e11b01
|
||||||
at first he have to login with telegram, i will send you the bots adress.
|
“Currency” :RUB | AMD | USD
|
||||||
2.1.1 if is not logged -> will see the QR or link for logging via telegram
|
Root:
|
||||||
2.1.2 if logged we need to ping server to check if he is active user. the expiration date (like day or 5 days) we will get from bakcend with session id
|
API.dexarmarket.ru
|
||||||
2.2 and when user is logged, that time he can do a payment
|
|
||||||
|
|
||||||
|
General Information
|
||||||
|
Check if server is available
|
||||||
|
Get Marketplaces
|
||||||
|
Set Marketplaces
|
||||||
|
Get Item
|
||||||
|
Delete Item
|
||||||
|
New Item
|
||||||
|
New Callback
|
||||||
|
New Question
|
||||||
|
Get random Items
|
||||||
|
Get items in category
|
||||||
|
Get searched items
|
||||||
|
Get Categories
|
||||||
|
Delete Category
|
||||||
|
New Category
|
||||||
|
Create new websession
|
||||||
|
Check websession status
|
||||||
|
Delete websession status
|
||||||
|
Add to cart
|
||||||
|
Create New QR code for cart checkout
|
||||||
|
Check QR code
|
||||||
|
item structure
|
||||||
|
category structure
|
||||||
|
Check if server is available
|
||||||
|
Client needs to periodically check if the server is available by sending “ping” to the client. On error corresponding message must be shown.
|
||||||
|
Protocol: https
|
||||||
|
Type: GET
|
||||||
|
Path: /ping
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Response (Error):
|
||||||
|
{
|
||||||
|
"message": "pong",
|
||||||
|
"status": "Wrong Header"
|
||||||
|
}
|
||||||
|
Response (OK):
|
||||||
|
{
|
||||||
|
"message": "pong",
|
||||||
|
"status": "Correct Header"
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
|
||||||
|
|
||||||
|
Get Marketplaces
|
||||||
|
Get Available Marketplaces
|
||||||
|
Protocol: https
|
||||||
|
Type: GET
|
||||||
|
Path: /marketplaces
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
[{“brand” : “dexar”,
|
||||||
|
“api”:”dexar.market”,
|
||||||
|
“bot”:”dexarmarket_bot”,
|
||||||
|
“languagies”:[“”am,”ru”,”en”],
|
||||||
|
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”, ”Yerevan - Armenia”]
|
||||||
|
“currency”:[“RUB, ”AMD”, ”USD”]
|
||||||
|
“icon”:”./dexar.market.png”},
|
||||||
|
{“brand” : “store”,
|
||||||
|
“api”:”dexarmarket.store”,
|
||||||
|
“bot”:”dexarstore_bot”,
|
||||||
|
“languagies”:[“”am,”ru”,”en”],
|
||||||
|
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||||
|
“currency”:[“”RUB,”AMD”,”USD”]
|
||||||
|
“icon”:”./dexarmarket.store.png”},
|
||||||
|
{“brand” : “Novo”,
|
||||||
|
“api”:”novo.market”,
|
||||||
|
“bot”:”novomarket_bot”,
|
||||||
|
“languagies”:[“”am,”ru”,”en”],
|
||||||
|
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||||
|
“currency”:[“”RUB,”AMD”,”USD”]
|
||||||
|
“icon”:”./novo.market.png”}]
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
|
||||||
|
|
||||||
|
Set Marketplaces
|
||||||
|
Get Available Marketplaces
|
||||||
|
Protocol: https
|
||||||
|
Type: PUT
|
||||||
|
Path: /marketplaces
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
[{“brand” : “dexar”,
|
||||||
|
“api”:”dexar.market”,
|
||||||
|
“languagies”:[“”am,”ru”,”en”],
|
||||||
|
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||||
|
“currency”:[“”RUB,”AMD”,”USD”]
|
||||||
|
“icon”:”./dexar.market.png”},
|
||||||
|
{“brand” : “store”,
|
||||||
|
“api”:”dexarmarket.store”,
|
||||||
|
“languagies”:[“”am,”ru”,”en”],
|
||||||
|
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||||
|
“currency”:[“”RUB,”AMD”,”USD”]
|
||||||
|
“icon”:”./dexarmarket.store.png”},
|
||||||
|
{“brand” : “Novo”,
|
||||||
|
“api”:”novo.market”,
|
||||||
|
“languagies”:[“”am,”ru”,”en”],
|
||||||
|
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||||
|
“currency”:[“”RUB,”AMD”,”USD”]
|
||||||
|
“icon”:”./novo.market.png”}]
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“status”:”Marketplace updated”
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
|
||||||
|
|
||||||
|
Get Item
|
||||||
|
Get Item by ID
|
||||||
|
Protocol: https
|
||||||
|
Type: GET
|
||||||
|
Path: /items/:itemID
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“itemID”:...
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
Delete Item
|
||||||
|
Delete the item
|
||||||
|
Protocol: https
|
||||||
|
Type: Delete
|
||||||
|
Path: /items/:itemID
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“status”:”Item was deleted”
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
New Item
|
||||||
|
Create new Item
|
||||||
|
Protocol: https
|
||||||
|
Type: POST
|
||||||
|
Path: /items/:itemID
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
“itemID”:...
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“itemID”:...
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
Update Item
|
||||||
|
Update the item
|
||||||
|
Protocol: https
|
||||||
|
Type: PUT
|
||||||
|
Path: /items/:itemID
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
“itemID”:...
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“status”:”Item updated”
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
|
||||||
|
|
||||||
|
New Callback
|
||||||
|
Update the item
|
||||||
|
Protocol: https
|
||||||
|
Type: POST
|
||||||
|
Path: /items/:itemID/callback
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
"rating": 5,
|
||||||
|
"comment": "Отличный товар!",
|
||||||
|
"sessionID": “ f02fe5d6-c6ae-4b2e-9b4d-687534e11b01”
|
||||||
|
"timestamp": "2026-02-28T12:00:00Z"
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong item"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“status”:”Callback added”
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
New Question
|
||||||
|
Update the item
|
||||||
|
Protocol: https
|
||||||
|
Type: POST
|
||||||
|
Path: /items/:itemID/questiion
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
"question": "some question!",
|
||||||
|
"sessionID": “ f02fe5d6-c6ae-4b2e-9b4d-687534e11b01”
|
||||||
|
"timestamp": "2026-02-28T12:00:00Z"
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong item"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“status”:”Questiion added”
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
|
||||||
|
|
||||||
|
Get random Items
|
||||||
|
Get given number of items from random categorues
|
||||||
|
Protocol: https
|
||||||
|
Type: GET
|
||||||
|
Path: /items/randomitems?count=15 // 20 is the default
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
[“itemID”:...]
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
Get items in category
|
||||||
|
Get all items in category and in all subcategories inside the category
|
||||||
|
Protocol: https
|
||||||
|
Type: GET
|
||||||
|
Path: /category/:categoryID?count=30, skip=60 // default skip=0, default count=20
|
||||||
|
|
||||||
|
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
[“itemID”:...]
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
|
||||||
|
|
||||||
|
Get searched items
|
||||||
|
Get all items in category and in all subcategories inside the category
|
||||||
|
Protocol: https
|
||||||
|
Type: GET
|
||||||
|
Path: /searchitems
|
||||||
|
Parameters:
|
||||||
|
{
|
||||||
|
search (string) — query text
|
||||||
|
categoryIDs (string) — e.g., 1,2,5 (includes all subcategories)
|
||||||
|
minPrice / maxPrice (float) — price range
|
||||||
|
tag (string) — e.g., sale
|
||||||
|
sort (string) — relevance (default), price_asc, price_desc, popular, rating
|
||||||
|
skip / count — default 0 / 20
|
||||||
|
}
|
||||||
|
Examples:
|
||||||
|
* ?search=iphone&sort=popular
|
||||||
|
* ?categoryIDs=1,5&minPrice=100&maxPrice=500
|
||||||
|
* ?tag=new&sort=price_asc&count=10
|
||||||
|
|
||||||
|
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
"total": 12,
|
||||||
|
"skip": 0,
|
||||||
|
"count": 12,
|
||||||
|
"isGlobal": false,
|
||||||
|
"items": [
|
||||||
|
{ "itemID": 101, "name": "..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
|
||||||
|
|
||||||
|
Get Categories
|
||||||
|
Get all available categories
|
||||||
|
Protocol: https
|
||||||
|
Type: GET
|
||||||
|
Path: /category
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“categoryID”:...
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
Delete Category
|
||||||
|
Delete EMPTY category, no items and no subcategories must present
|
||||||
|
Protocol: https
|
||||||
|
Type: Delete
|
||||||
|
Path: /category/:categoryID
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“status”:”Category was deleted”
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
New Category
|
||||||
|
Create new category
|
||||||
|
Protocol: https
|
||||||
|
Type: POST
|
||||||
|
Path: /category/
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
“CategoryID”:...
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“CategoryID”:...
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
Update Category
|
||||||
|
Update existing category
|
||||||
|
Protocol: https
|
||||||
|
Type: PUT
|
||||||
|
Path: /category/:categoryID
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
“itemID”:...
|
||||||
|
}
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong header"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
“status”:”Category was updated”
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
Create new websession
|
||||||
|
Creates a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
|
||||||
|
Protocol: https
|
||||||
|
Type POST
|
||||||
|
Path /websession
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Response (OK):
|
||||||
|
{
|
||||||
|
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||||
|
"userId" : "",
|
||||||
|
"expires" : "sessionId",
|
||||||
|
"userSessionId": "",
|
||||||
|
"status": false
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
Check websession status
|
||||||
|
Check if the user is already logged in. a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
|
||||||
|
Protocol: https
|
||||||
|
Type GET
|
||||||
|
Path /websession/:webSessionID
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
Response (OK):
|
||||||
|
{
|
||||||
|
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||||
|
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
|
||||||
|
"expires" : "sessionId",
|
||||||
|
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
|
||||||
|
"x-Region" : "Moscow",
|
||||||
|
"x-Language" : "RU",
|
||||||
|
"currency" : "RUB",
|
||||||
|
"Status": true,
|
||||||
|
"cart": [
|
||||||
|
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||||
|
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||||
|
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
Delete websession status
|
||||||
|
Delete the session to log out from the system.
|
||||||
|
Protocol: https
|
||||||
|
Type DELETE
|
||||||
|
Path /websession/:webSessionID
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
}
|
||||||
|
Response (OK):
|
||||||
|
{
|
||||||
|
“status”:”User logged out”
|
||||||
|
}
|
||||||
|
|
||||||
|
________________
|
||||||
|
Add to cart
|
||||||
|
Add a all item to users (session) cart
|
||||||
|
Protocol: https
|
||||||
|
Type Post
|
||||||
|
Path /websession/:webSessionID
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
[
|
||||||
|
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||||
|
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||||
|
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Response (OK):
|
||||||
|
{
|
||||||
|
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||||
|
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
|
||||||
|
"expires" : "sessionId",
|
||||||
|
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
|
||||||
|
"Status": true,
|
||||||
|
"cart": [
|
||||||
|
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||||
|
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||||
|
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
Create New QR code for cart checkout
|
||||||
|
Create New QR for payment via SBP
|
||||||
|
Protocol: https
|
||||||
|
Type POST
|
||||||
|
Path /websession/:webSessionID/qr
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "wrong key"
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
"qrId": "BD10002CI1V3JP1T8QR8TIQ8K35RBVQB",
|
||||||
|
"qrStatus": "NEW",
|
||||||
|
"qrExpirationDate": "2025-11-20T10:10:44Z",
|
||||||
|
"Payload": "https://qr.nspk.ru/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB?type=02&bank=100000000007&sum=1000&cur=RUB&crc=8ACC",
|
||||||
|
"qrUrl": "https://e-commerce.raiffeisen.ru/api/sbp/v1/qr/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB/image"
|
||||||
|
}
|
||||||
|
________________
|
||||||
|
|
||||||
|
|
||||||
|
Check QR code
|
||||||
|
Check QR status
|
||||||
|
Protocol: https
|
||||||
|
Type GET
|
||||||
|
Path /websession/:webSessionID/:qrID
|
||||||
|
Request Parameters:
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Response !=200(Error):
|
||||||
|
{
|
||||||
|
"error": "Error from the bank "
|
||||||
|
}
|
||||||
|
Response =200(OK):
|
||||||
|
{
|
||||||
|
"additionalInfo": "",
|
||||||
|
"paymentPurpose": "",
|
||||||
|
"amount": 10,
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"createDate": "2025-11-20T13:17:20.453884+03:00",
|
||||||
|
"currency": "RUB",
|
||||||
|
"order": "102_540",
|
||||||
|
"paymentStatus": "NO_INFO", //check for SUCCESS
|
||||||
|
"qrId": "BD1000263VS7G81D8JCP5FHFTFEH38MT",
|
||||||
|
"transactionDate": "",
|
||||||
|
"transactionId": 0,
|
||||||
|
"qrExpirationDate": "2025-11-20T13:32:20+03:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 8. Авторизация (вход через Telegram)
|
||||||
|
|
||||||
|
|
||||||
|
Авторизация **через Telegram** с **cookie-сессиями** (HttpOnly, Secure, SameSite=None).
|
||||||
|
|
||||||
|
|
||||||
|
Все auth-эндпоинты должны поддерживать CORS с `credentials: true`.
|
||||||
|
|
||||||
|
|
||||||
|
### Процесс авторизации
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Пользователь нажимает «Оформить заказ» → не авторизован → показывается диалог входа
|
||||||
|
2. Нажимает «Войти через Telegram» → открывается https://t.me/{bot}?start=auth_{callback}
|
||||||
|
3. Пользователь запускает бота в Telegram
|
||||||
|
4. Бот отправляет данные пользователя → бэкенд /auth/telegram/callback
|
||||||
|
5. Бэкенд создаёт сессию → устанавливает Set-Cookie
|
||||||
|
6. Фронтенд опрашивает GET /auth/session каждые 3 секунды
|
||||||
|
7. Сессия обнаружена → диалог закрывается → оформление заказа продолжается
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
### `GET /auth/session` — Проверить текущую сессию
|
||||||
|
|
||||||
|
|
||||||
|
**Запрос:** Только cookie (сессионная cookie, установленная бэкендом).
|
||||||
|
|
||||||
|
|
||||||
|
**Ответ `200`** (авторизован):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "sess_abc123",
|
||||||
|
"telegramUserId": 123456789,
|
||||||
|
"username": "john_doe",
|
||||||
|
"displayName": "John Doe",
|
||||||
|
"active": true,
|
||||||
|
"expiresAt": "2026-03-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
**Ответ `200`** (сессия истекла):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "sess_abc123",
|
||||||
|
"telegramUserId": 123456789,
|
||||||
|
"username": "john_doe",
|
||||||
|
"displayName": "John Doe",
|
||||||
|
"active": false,
|
||||||
|
"expiresAt": "2026-02-27T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
**Ответ `401`** (нет сессии):
|
||||||
|
```json
|
||||||
|
{ "error": "No active session" }
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
**Объект AuthSession:**
|
||||||
|
|
||||||
|
|
||||||
|
| Поле | Тип | Обязат. | Описание |
|
||||||
|
|------------------|---------|---------|-------------------------------------------|
|
||||||
|
| `sessionId` | string | да | Уникальный ID сессии |
|
||||||
|
| `telegramUserId` | number | да | ID пользователя в Telegram |
|
||||||
|
| `username` | string? | нет | @username в Telegram (может быть null) |
|
||||||
|
| `displayName` | string | да | Отображаемое имя (имя + фамилия) |
|
||||||
|
| `active` | boolean | да | Действительна ли сессия |
|
||||||
|
| `expiresAt` | string | да | Дата истечения в формате ISO 8601 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
### `GET /auth/telegram/callback` — Callback авторизации Telegram-бота
|
||||||
|
|
||||||
|
|
||||||
|
Вызывается Telegram-ботом после авторизации пользователя.
|
||||||
|
|
||||||
|
|
||||||
|
**Тело запроса (от бота):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123456789,
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"username": "john_doe",
|
||||||
|
"photo_url": "https://t.me/i/userpic/...",
|
||||||
|
"auth_date": 1709100000,
|
||||||
|
"hash": "abc123def456..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Бот должен:
|
||||||
|
1. Слушать команду `/start auth_{callbackUrl}`
|
||||||
|
2. Извлечь callback URL
|
||||||
|
3. Отправить данные пользователя (`id`, `first_name`, `username` и т.д.) на этот callback URL
|
||||||
|
4. Callback URL: `{apiUrl}/auth/telegram/callback`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Полный справочник эндпоинтов
|
||||||
|
|
||||||
|
|
||||||
|
### Новые эндпоинты
|
||||||
|
|
||||||
|
|
||||||
|
| Метод | Путь | Описание | Авторизация |
|
||||||
|
|--------|---------------------------|---------------------------------|-------------|
|
||||||
|
| `GET` | `/regions` | Список доступных регионов | Нет |
|
||||||
|
| `GET` | `/auth/session` | Проверка текущей сессии | Cookie |
|
||||||
|
| `GET` | `/auth/telegram/callback` | Callback авторизации через бота | Нет (бот) |
|
||||||
|
| `POST` | `/auth/logout` | Завершение сессии | Cookie |
|
||||||
|
|
||||||
|
|
||||||
|
________________
|
||||||
|
|
||||||
|
|
||||||
|
item structure
|
||||||
|
CategoryID uint64 `json:"categoryID" binding:"required"`
|
||||||
|
ItemID uint64 `json:"itemID" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Discount float32 `json:"discount" `
|
||||||
|
Rating float32 `json:"rating" binding:"required"`
|
||||||
|
Visible bool `json:"rating"`
|
||||||
|
Priority uint64 `json:"priority"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Badges []string `json:"badges"`
|
||||||
|
Details []itemdetail `json:"itemdetails"`
|
||||||
|
Colour string `json:"colour" binding:"required"`
|
||||||
|
Size string `json:"size" binding:"required"`
|
||||||
|
Price float32 `json:"price" binding:"required"`
|
||||||
|
Currency string `json:"currency" binding:"required"`
|
||||||
|
Remaining uint64 `json:"remaining" binding:"required"`
|
||||||
|
Names []itemname `json:"names"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Descriptions []itemdescription `json:"descriptions" `
|
||||||
|
Language string `json:"language"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Attributes []attribute `json:"attributes" binding:"required"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Photos []photo `json:"photos"`
|
||||||
|
Type string `json:"type" binding:"required"` //video || photo
|
||||||
|
URL string `json:"url" binding:"required"`
|
||||||
|
Questions []question `json:"questions"`
|
||||||
|
Question string `json:"question" `
|
||||||
|
Answer string `json:"answer" `
|
||||||
|
Like uint64 `json:"like" `
|
||||||
|
Dislike uint64 `json:"dislike" `
|
||||||
|
Visits uint64 `json:"visits"`
|
||||||
|
Callbacks []callback `json:"callbacks" binding:"required"`
|
||||||
|
Rating float32 `json:"rating,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
Userid string `json:"userID"`
|
||||||
|
Answer string `json:"answer"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
PartnerID []string `json:"partnerID" binding:"required"`
|
||||||
|
|
||||||
|
|
||||||
|
category structure
|
||||||
|
CategoryID uint64 `json:"categoryID" binding:"required"`
|
||||||
|
ParentID uint64 `json:"parentID" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Visible bool `json:"visible" `
|
||||||
|
Priority uint64 `json:"priority" `
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
WideIcon string `json:"wideicon"`
|
||||||
|
ItemsCount uint64
|
||||||
|
CategoriesCount uint64
|
||||||
|
Names []itemname `json:"names"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Value string `json:"value"`
|
||||||
@@ -30,7 +30,9 @@
|
|||||||
{
|
{
|
||||||
"name": "api-cache",
|
"name": "api-cache",
|
||||||
"urls": [
|
"urls": [
|
||||||
"/api/**"
|
"/api/**",
|
||||||
|
"https://api.dexarmarket.ru:445/**",
|
||||||
|
"https://api.novo.market:444/**"
|
||||||
],
|
],
|
||||||
"cacheConfig": {
|
"cacheConfig": {
|
||||||
"maxSize": 100,
|
"maxSize": 100,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
// Кэшируем списки категорий, товары категорий и отдельные товары
|
// Кэшируем списки категорий, товары категорий и отдельные товары
|
||||||
const isCategoryList = /\/category$/.test(req.url);
|
const isCategoryList = /\/category$/.test(req.url);
|
||||||
const isCategoryItems = /\/category\/\d+/.test(req.url);
|
const isCategoryItems = /\/category\/\d+/.test(req.url);
|
||||||
const isItem = /\/item\/\d+/.test(req.url);
|
const isItem = /\/items\/\d+/.test(req.url);
|
||||||
if (!isCategoryList && !isCategoryItems && !isItem) {
|
if (!isCategoryList && !isCategoryItems && !isItem) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -735,34 +735,28 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
return respond([]);
|
return respond([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /cart (add to cart / create payment)
|
// ── POST /websession/:id (add to cart)
|
||||||
if (url.endsWith('/cart') && req.method === 'POST') {
|
if (url.match(/\/websession\/[^/]+$/) && req.method === 'POST') {
|
||||||
const body = req.body as any;
|
return respond({
|
||||||
if (body?.amount) {
|
sessionId: 'mock-session',
|
||||||
// Payment mock
|
Status: true,
|
||||||
return respond({
|
cart: req.body
|
||||||
qrId: 'mock-qr-' + Date.now(),
|
});
|
||||||
qrStatus: 'CREATED',
|
|
||||||
qrExpirationDate: new Date(Date.now() + 180000).toISOString(),
|
|
||||||
payload: 'https://example.com/pay/mock',
|
|
||||||
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment'
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
return respond({ message: 'Added (mock)' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PATCH /cart
|
// ── POST /websession/:id/qr (create payment QR)
|
||||||
if (url.endsWith('/cart') && req.method === 'PATCH') {
|
if (url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'POST') {
|
||||||
return respond({ message: 'Updated (mock)' });
|
return respond({
|
||||||
|
qrId: 'mock-qr-' + Date.now(),
|
||||||
|
qrStatus: 'NEW',
|
||||||
|
qrExpirationDate: new Date(Date.now() + 180000).toISOString(),
|
||||||
|
Payload: 'https://example.com/pay/mock',
|
||||||
|
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment'
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DELETE /cart
|
// ── POST /items/:id/callback (review)
|
||||||
if (url.endsWith('/cart') && req.method === 'DELETE') {
|
if (url.match(/\/items\/\d+\/callback$/) && req.method === 'POST') {
|
||||||
return respond({ message: 'Removed (mock)' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /comment
|
|
||||||
if (url.endsWith('/comment') && req.method === 'POST') {
|
|
||||||
return respond({ message: 'Review submitted (mock)' }, 200);
|
return respond({ message: 'Review submitted (mock)' }, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,8 +765,8 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
return respond({ message: 'Email sent (mock)' }, 200);
|
return respond({ message: 'Email sent (mock)' }, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /qr/payment/:id (always return success for testing)
|
// ── GET /websession/:id/:qrId (check QR payment status)
|
||||||
if (url.includes('/qr/payment/') && req.method === 'GET') {
|
if (url.match(/\/websession\/[^/]+\/[^/]+$/) && !url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'GET') {
|
||||||
return respond({
|
return respond({
|
||||||
paymentStatus: 'SUCCESS',
|
paymentStatus: 'SUCCESS',
|
||||||
code: 'SUCCESS',
|
code: 'SUCCESS',
|
||||||
@@ -785,8 +779,7 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
paymentPurpose: '',
|
paymentPurpose: '',
|
||||||
createDate: new Date().toISOString(),
|
createDate: new Date().toISOString(),
|
||||||
order: 'mock-order',
|
order: 'mock-order',
|
||||||
qrExpirationDate: new Date().toISOString(),
|
qrExpirationDate: new Date().toISOString()
|
||||||
phoneNumber: ''
|
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ItemName } from './item.model';
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
categoryID: number;
|
categoryID: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5,7 +7,10 @@ export interface Category {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
wideBanner?: string;
|
wideBanner?: string;
|
||||||
itemCount?: number;
|
itemCount?: number;
|
||||||
|
categoriesCount?: number;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
names?: ItemName[];
|
||||||
|
translations?: Record<string, CategoryTranslation>;
|
||||||
|
|
||||||
// BackOffice API fields
|
// BackOffice API fields
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface Question {
|
|||||||
answer: string;
|
answer: string;
|
||||||
upvotes: number;
|
upvotes: number;
|
||||||
downvotes: number;
|
downvotes: number;
|
||||||
|
like?: number;
|
||||||
|
dislike?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Localized name entry from backend */
|
/** Localized name entry from backend */
|
||||||
@@ -60,6 +62,16 @@ export interface ItemAttribute {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Item variant detail (price, size, colour per variant) */
|
||||||
|
export interface ItemDetail {
|
||||||
|
color?: string;
|
||||||
|
colour?: string;
|
||||||
|
size?: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
categoryID: number;
|
categoryID: number;
|
||||||
itemID: number;
|
itemID: number;
|
||||||
@@ -95,6 +107,8 @@ export interface Item {
|
|||||||
subcategoryId?: string;
|
subcategoryId?: string;
|
||||||
translations?: Record<string, ItemTranslation>;
|
translations?: Record<string, ItemTranslation>;
|
||||||
comments?: Comment[];
|
comments?: Comment[];
|
||||||
|
visits?: number;
|
||||||
|
itemDetails?: ItemDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CartItem extends Item {
|
export interface CartItem extends Item {
|
||||||
|
|||||||
@@ -182,37 +182,45 @@ export class CartComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createPayment(): void {
|
createPayment(): void {
|
||||||
const telegramUsername = this.getTelegramUsername();
|
const sessionId = this.authService.session()?.sessionId || '';
|
||||||
const userId = this.getUserId();
|
if (!sessionId) {
|
||||||
const orderId = this.generateOrderId();
|
this.paymentStatus.set('timeout');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const paymentData = {
|
// First sync cart items to server via websession, then create QR
|
||||||
amount: this.totalPrice(),
|
const cartItems = this.items().map((item: CartItem) => ({
|
||||||
currency: this.langService.currentCurrency(),
|
itemID: item.itemID,
|
||||||
siteuserID: userId,
|
quantity: item.quantity,
|
||||||
siteorderID: orderId,
|
colour: item.colour || '',
|
||||||
redirectUrl: '',
|
size: item.size || '',
|
||||||
telegramUsername: telegramUsername,
|
price: item.discount > 0
|
||||||
items: this.items().map((item: CartItem) => ({
|
? item.price * (1 - item.discount / 100)
|
||||||
itemID: item.itemID,
|
: item.price,
|
||||||
price: item.discount > 0
|
}));
|
||||||
? item.price * (1 - item.discount / 100)
|
|
||||||
: item.price,
|
|
||||||
name: item.name,
|
|
||||||
quantity: item.quantity
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
|
|
||||||
this.apiService.createPayment(paymentData).subscribe({
|
this.apiService.addToCart(sessionId, cartItems).subscribe({
|
||||||
next: (response) => {
|
next: () => {
|
||||||
this.paymentId.set(response.qrId);
|
this.apiService.createPayment(sessionId).subscribe({
|
||||||
this.qrCodeUrl.set(response.qrUrl);
|
next: (response) => {
|
||||||
this.paymentUrl.set(response.payload);
|
this.paymentId.set(response.qrId);
|
||||||
this.paymentStatus.set('waiting');
|
this.qrCodeUrl.set(response.qrUrl);
|
||||||
this.startPolling();
|
this.paymentUrl.set(response.Payload);
|
||||||
|
this.paymentStatus.set('waiting');
|
||||||
|
this.startPolling();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Error creating payment:', err);
|
||||||
|
this.paymentStatus.set('timeout');
|
||||||
|
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||||
|
this.closeTimeout = setTimeout(() => {
|
||||||
|
this.closePaymentPopup();
|
||||||
|
}, PAYMENT_ERROR_CLOSE_MS);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Error creating payment:', err);
|
console.error('Error syncing cart:', err);
|
||||||
this.paymentStatus.set('timeout');
|
this.paymentStatus.set('timeout');
|
||||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||||
this.closeTimeout = setTimeout(() => {
|
this.closeTimeout = setTimeout(() => {
|
||||||
@@ -228,7 +236,8 @@ export class CartComponent implements OnDestroy {
|
|||||||
.pipe(
|
.pipe(
|
||||||
take(this.maxChecks), // maximum 36 checks (3 minutes)
|
take(this.maxChecks), // maximum 36 checks (3 minutes)
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
return this.apiService.checkPaymentStatus(this.paymentId());
|
const sessionId = this.authService.session()?.sessionId || '';
|
||||||
|
return this.apiService.checkPaymentStatus(sessionId, this.paymentId());
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
@@ -283,27 +292,6 @@ export class CartComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTelegramUsername(): string {
|
|
||||||
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
|
|
||||||
const user = window.Telegram.WebApp.initDataUnsafe.user;
|
|
||||||
return user.username || 'nontelegram';
|
|
||||||
}
|
|
||||||
return 'nontelegram';
|
|
||||||
}
|
|
||||||
|
|
||||||
private getUserId(): string {
|
|
||||||
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
|
|
||||||
return window.Telegram.WebApp.initDataUnsafe.user.id.toString();
|
|
||||||
}
|
|
||||||
return `web_${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateOrderId(): string {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
|
||||||
return `order_${timestamp}_${random}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitEmail(): void {
|
submitEmail(): void {
|
||||||
// Mark both fields as touched
|
// Mark both fields as touched
|
||||||
this.emailTouched.set(true);
|
this.emailTouched.set(true);
|
||||||
|
|||||||
@@ -48,13 +48,13 @@
|
|||||||
<a [routerLink]="['/category', cat.categoryID] | langRoute" class="category-card">
|
<a [routerLink]="['/category', cat.categoryID] | langRoute" class="category-card">
|
||||||
<div class="category-image">
|
<div class="category-image">
|
||||||
@if (cat.icon) {
|
@if (cat.icon) {
|
||||||
<img [src]="cat.icon" [alt]="cat.name" loading="lazy" decoding="async" />
|
<img [src]="cat.icon" [alt]="categoryName(cat)" loading="lazy" decoding="async" />
|
||||||
} @else {
|
} @else {
|
||||||
<div class="category-fallback">{{ cat.name.charAt(0) }}</div>
|
<div class="category-fallback">{{ categoryName(cat).charAt(0) }}</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="category-info">
|
<div class="category-info">
|
||||||
<h3 class="category-name">{{ cat.name }}</h3>
|
<h3 class="category-name">{{ categoryName(cat) }}</h3>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Subscription } from 'rxjs';
|
|||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../i18n/translate.service';
|
import { TranslateService } from '../../i18n/translate.service';
|
||||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField, getTranslatedCategoryName } from '../../utils/item.utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-subcategories',
|
selector: 'app-subcategories',
|
||||||
@@ -59,7 +59,7 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
|||||||
next: (cats) => {
|
next: (cats) => {
|
||||||
this.categories.set(cats);
|
this.categories.set(cats);
|
||||||
const parent = cats.find(c => c.categoryID === parentID);
|
const parent = cats.find(c => c.categoryID === parentID);
|
||||||
this.parentName.set(parent ? parent.name : this.i18n.t('home.categoriesTitle'));
|
this.parentName.set(parent ? getTranslatedCategoryName(parent, this.langService.currentLanguage()) : this.i18n.t('home.categoriesTitle'));
|
||||||
|
|
||||||
// Check for nested subcategories from API response (backOffice format)
|
// Check for nested subcategories from API response (backOffice format)
|
||||||
const nested = parent?.subcategories || [];
|
const nested = parent?.subcategories || [];
|
||||||
@@ -135,4 +135,6 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
|||||||
readonly getBadgeClass = getBadgeClass;
|
readonly getBadgeClass = getBadgeClass;
|
||||||
|
|
||||||
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
||||||
|
|
||||||
|
categoryName(cat: Category): string { return getTranslatedCategoryName(cat, this.langService.currentLanguage()); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,15 +66,15 @@
|
|||||||
<a [routerLink]="['/category', category.categoryID] | langRoute" class="novo-category-card">
|
<a [routerLink]="['/category', category.categoryID] | langRoute" class="novo-category-card">
|
||||||
<div class="novo-category-image">
|
<div class="novo-category-image">
|
||||||
@if (category.icon) {
|
@if (category.icon) {
|
||||||
<img [src]="category.icon" [alt]="category.name" loading="lazy" />
|
<img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" />
|
||||||
} @else {
|
} @else {
|
||||||
<div class="novo-category-placeholder">
|
<div class="novo-category-placeholder">
|
||||||
<span>{{ category.name.charAt(0) }}</span>
|
<span>{{ categoryName(category).charAt(0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="novo-category-info">
|
<div class="novo-category-info">
|
||||||
<h3>{{ category.name }}</h3>
|
<h3>{{ categoryName(category) }}</h3>
|
||||||
<span class="novo-category-arrow">→</span>
|
<span class="novo-category-arrow">→</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -154,15 +154,15 @@
|
|||||||
[class.dexar-category-card--wide]="isWideCategory(category.categoryID)">
|
[class.dexar-category-card--wide]="isWideCategory(category.categoryID)">
|
||||||
<div class="dexar-category-image">
|
<div class="dexar-category-image">
|
||||||
@if (isWideCategory(category.categoryID) && category.wideBanner) {
|
@if (isWideCategory(category.categoryID) && category.wideBanner) {
|
||||||
<img [src]="category.wideBanner" [alt]="category.name" loading="lazy" decoding="async" />
|
<img [src]="category.wideBanner" [alt]="categoryName(category)" loading="lazy" decoding="async" />
|
||||||
} @else if (category.icon) {
|
} @else if (category.icon) {
|
||||||
<img [src]="category.icon" [alt]="category.name" loading="lazy" decoding="async" />
|
<img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" decoding="async" />
|
||||||
} @else {
|
} @else {
|
||||||
<div class="dexar-category-fallback">{{ category.name.charAt(0) }}</div>
|
<div class="dexar-category-fallback">{{ categoryName(category).charAt(0) }}</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="dexar-category-info">
|
<div class="dexar-category-info">
|
||||||
<h3 class="dexar-category-name">{{ category.name }}</h3>
|
<h3 class="dexar-category-name">{{ categoryName(category) }}</h3>
|
||||||
<p class="dexar-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.categoryID) } }}</p>
|
<p class="dexar-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.categoryID) } }}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Router, RouterLink } from '@angular/router';
|
|||||||
import { ApiService, LanguageService } from '../../services';
|
import { ApiService, LanguageService } from '../../services';
|
||||||
import { Category } from '../../models';
|
import { Category } from '../../models';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { getTranslatedCategoryName } from '../../utils/item.utils';
|
||||||
import { ItemsCarouselComponent } from '../../components/items-carousel/items-carousel.component';
|
import { ItemsCarouselComponent } from '../../components/items-carousel/items-carousel.component';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
@@ -123,6 +124,10 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate([`/${lang}/search`]);
|
this.router.navigate([`/${lang}/search`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
categoryName(cat: Category): string {
|
||||||
|
return getTranslatedCategoryName(cat, this.langService.currentLanguage());
|
||||||
|
}
|
||||||
|
|
||||||
scrollToCatalog(): void {
|
scrollToCatalog(): void {
|
||||||
const target = document.getElementById('catalog');
|
const target = document.getElementById('catalog');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DecimalPipe } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services';
|
import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { Item, DescriptionField } from '../../models';
|
import { Item, DescriptionField } from '../../models';
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
@@ -42,6 +43,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private seoService = inject(SeoService);
|
private seoService = inject(SeoService);
|
||||||
private i18n = inject(TranslateService);
|
private i18n = inject(TranslateService);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@@ -207,8 +209,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
|||||||
itemID: currentItem.itemID,
|
itemID: currentItem.itemID,
|
||||||
rating: this.newReview.rating,
|
rating: this.newReview.rating,
|
||||||
comment: this.newReview.comment.trim(),
|
comment: this.newReview.comment.trim(),
|
||||||
username: this.newReview.anonymous ? null : this.getUserDisplayName(),
|
sessionID: this.authService.session()?.sessionId || '',
|
||||||
userId: this.telegramService.getUserId(),
|
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ export class ApiService {
|
|||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
/** Map API language codes (RU/EN/AM) → frontend codes (ru/en/hy) */
|
||||||
|
private normalizeLang(apiLang: string): string {
|
||||||
|
const map: Record<string, string> = { 'RU': 'ru', 'EN': 'en', 'AM': 'hy' };
|
||||||
|
return map[apiLang] || apiLang.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve relative image URLs (e.g. ./images/x.webp) against API base */
|
||||||
|
private resolveImageUrl(url: string): string {
|
||||||
|
if (!url) return '';
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return url;
|
||||||
|
if (url.startsWith('./')) return `${this.baseUrl}/${url.slice(2)}`;
|
||||||
|
return `${this.baseUrl}/${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize an item from the API response — supports both
|
* Normalize an item from the API response — supports both
|
||||||
* legacy marketplace format and the new backOffice API format.
|
* legacy marketplace format and the new backOffice API format.
|
||||||
@@ -26,6 +40,22 @@ export class ApiService {
|
|||||||
const { partnerID, ...rest } = raw;
|
const { partnerID, ...rest } = raw;
|
||||||
const item: Item = { ...rest };
|
const item: Item = { ...rest };
|
||||||
|
|
||||||
|
// Extract price/currency/remaining/colour/size from itemDetails[]
|
||||||
|
// Note: Go struct tag is "itemdetails" but actual API may send "itemDetails"
|
||||||
|
const details = raw.itemDetails || raw.itemdetails;
|
||||||
|
if (details && Array.isArray(details) && details.length > 0) {
|
||||||
|
const detail = details[0];
|
||||||
|
item.itemDetails = raw.itemDetails;
|
||||||
|
if (item.price == null || item.price === 0) item.price = detail.price;
|
||||||
|
if (!item.currency) item.currency = detail.currency;
|
||||||
|
if (!item.colour) item.colour = detail.colour || detail.color || '';
|
||||||
|
if (!item.size) item.size = detail.size || '';
|
||||||
|
// Use remaining from detail for stock level
|
||||||
|
if (raw.remaining == null && detail.remaining != null) {
|
||||||
|
(raw as any).remaining = detail.remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Map backOffice string id → legacy numeric itemID
|
// Map backOffice string id → legacy numeric itemID
|
||||||
if (raw.id != null && raw.itemID == null) {
|
if (raw.id != null && raw.itemID == null) {
|
||||||
item.id = String(raw.id);
|
item.id = String(raw.id);
|
||||||
@@ -37,13 +67,16 @@ export class ApiService {
|
|||||||
item.photos = raw.imgs.map((url: string) => ({ url }));
|
item.photos = raw.imgs.map((url: string) => ({ url }));
|
||||||
}
|
}
|
||||||
// Normalize photo type: API sends type='video'|'photo', template checks .video
|
// Normalize photo type: API sends type='video'|'photo', template checks .video
|
||||||
|
// Also resolve relative URLs (e.g. ./images/x.webp) against API base
|
||||||
if (item.photos) {
|
if (item.photos) {
|
||||||
item.photos = item.photos.map((p: any) => ({
|
item.photos = item.photos.map((p: any) => ({
|
||||||
...p,
|
...p,
|
||||||
|
url: this.resolveImageUrl(p.url),
|
||||||
video: p.video || (p.type === 'video' ? p.url : undefined),
|
video: p.video || (p.type === 'video' ? p.url : undefined),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
item.imgs = raw.imgs || raw.photos?.map((p: any) => p.url) || [];
|
item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u))
|
||||||
|
|| item.photos?.map((p: any) => p.url) || [];
|
||||||
|
|
||||||
// Map backOffice description (key-value array) → legacy description string
|
// Map backOffice description (key-value array) → legacy description string
|
||||||
if (Array.isArray(raw.description)) {
|
if (Array.isArray(raw.description)) {
|
||||||
@@ -54,12 +87,22 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map backend names[] → translations (multi-lang name support)
|
// Map backend names[] → translations (multi-lang name support)
|
||||||
|
// Note: API has typo "valuue" in some responses, handle both
|
||||||
if (raw.names && Array.isArray(raw.names)) {
|
if (raw.names && Array.isArray(raw.names)) {
|
||||||
item.names = raw.names;
|
item.names = raw.names;
|
||||||
if (!item.translations) item.translations = {};
|
if (!item.translations) item.translations = {};
|
||||||
for (const entry of raw.names) {
|
for (const entry of raw.names) {
|
||||||
if (!item.translations[entry.language]) item.translations[entry.language] = {};
|
const lang = this.normalizeLang(entry.language);
|
||||||
item.translations[entry.language].name = entry.value;
|
const val = entry.value || entry.valuue || '';
|
||||||
|
if (val) {
|
||||||
|
if (!item.translations[lang]) item.translations[lang] = {};
|
||||||
|
item.translations[lang].name = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: if top-level name is missing, use first available translation
|
||||||
|
if (!item.name && raw.names.length > 0) {
|
||||||
|
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 || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,17 +111,18 @@ export class ApiService {
|
|||||||
item.descriptions = raw.descriptions;
|
item.descriptions = raw.descriptions;
|
||||||
if (!item.translations) item.translations = {};
|
if (!item.translations) item.translations = {};
|
||||||
for (const entry of raw.descriptions) {
|
for (const entry of raw.descriptions) {
|
||||||
if (!item.translations[entry.language]) item.translations[entry.language] = {};
|
const lang = this.normalizeLang(entry.language);
|
||||||
item.translations[entry.language].simpleDescription = entry.value;
|
if (!item.translations[lang]) item.translations[lang] = {};
|
||||||
|
item.translations[lang].simpleDescription = entry.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve attributes from backend
|
// Preserve attributes from backend
|
||||||
item.attributes = raw.attributes || [];
|
item.attributes = raw.attributes || [];
|
||||||
|
|
||||||
// Preserve colour & size
|
// Preserve colour & size (only if not already set from itemDetails)
|
||||||
item.colour = raw.colour || '';
|
if (!item.colour) item.colour = raw.colour || '';
|
||||||
item.size = raw.size || '';
|
if (!item.size) item.size = raw.size || '';
|
||||||
|
|
||||||
// Map backOffice comments → legacy callbacks
|
// Map backOffice comments → legacy callbacks
|
||||||
if (raw.comments && (!raw.callbacks || raw.callbacks.length === 0)) {
|
if (raw.comments && (!raw.callbacks || raw.callbacks.length === 0)) {
|
||||||
@@ -107,8 +151,12 @@ export class ApiService {
|
|||||||
item.rating = item.rating || 0;
|
item.rating = item.rating || 0;
|
||||||
|
|
||||||
// Defaults
|
// Defaults
|
||||||
|
item.name = item.name || '';
|
||||||
|
item.price = item.price ?? 0;
|
||||||
item.discount = item.discount || 0;
|
item.discount = item.discount || 0;
|
||||||
item.remainings = item.remainings || (raw.quantity != null
|
item.remainings = item.remainings || (raw.remaining != null
|
||||||
|
? (raw.remaining <= 0 ? 'out' : raw.remaining <= 5 ? 'low' : raw.remaining <= 20 ? 'medium' : 'high')
|
||||||
|
: raw.quantity != null
|
||||||
? (raw.quantity <= 0 ? 'out' : raw.quantity <= 5 ? 'low' : raw.quantity <= 20 ? 'medium' : 'high')
|
? (raw.quantity <= 0 ? 'out' : raw.quantity <= 5 ? 'low' : raw.quantity <= 20 ? 'medium' : 'high')
|
||||||
: 'high');
|
: 'high');
|
||||||
item.currency = item.currency || 'RUB';
|
item.currency = item.currency || 'RUB';
|
||||||
@@ -120,6 +168,16 @@ export class ApiService {
|
|||||||
item.translations = item.translations || raw.translations || {};
|
item.translations = item.translations || raw.translations || {};
|
||||||
item.visible = raw.visible ?? true;
|
item.visible = raw.visible ?? true;
|
||||||
item.priority = raw.priority ?? 0;
|
item.priority = raw.priority ?? 0;
|
||||||
|
item.visits = raw.visits ?? 0;
|
||||||
|
|
||||||
|
// Map question like/dislike → upvotes/downvotes
|
||||||
|
if (item.questions) {
|
||||||
|
item.questions = item.questions.map((q: any) => ({
|
||||||
|
...q,
|
||||||
|
upvotes: q.upvotes ?? q.like ?? 0,
|
||||||
|
downvotes: q.downvotes ?? q.dislike ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -149,9 +207,41 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
cat.img = raw.img || raw.icon;
|
cat.img = raw.img || raw.icon;
|
||||||
|
|
||||||
|
// Resolve relative icon/image URLs
|
||||||
|
if (cat.icon) cat.icon = this.resolveImageUrl(cat.icon);
|
||||||
|
if (cat.img) cat.img = this.resolveImageUrl(cat.img);
|
||||||
|
|
||||||
|
// Map backend wideicon → wideBanner
|
||||||
|
if (raw.wideicon && !cat.wideBanner) {
|
||||||
|
cat.wideBanner = raw.wideicon;
|
||||||
|
}
|
||||||
|
|
||||||
cat.parentID = raw.parentID ?? 0;
|
cat.parentID = raw.parentID ?? 0;
|
||||||
cat.visible = raw.visible ?? true;
|
cat.visible = raw.visible ?? true;
|
||||||
cat.priority = raw.priority ?? 0;
|
cat.priority = raw.priority ?? 0;
|
||||||
|
cat.itemCount = raw.itemCount ?? raw.ItemsCount ?? 0;
|
||||||
|
cat.categoriesCount = raw.categoriesCount ?? raw.CategoriesCount ?? 0;
|
||||||
|
|
||||||
|
// Map backend names[] → translations (multi-lang name support)
|
||||||
|
// Note: API has typo "valuue" in some responses, handle both
|
||||||
|
if (raw.names && Array.isArray(raw.names)) {
|
||||||
|
cat.names = raw.names;
|
||||||
|
cat.translations = cat.translations || {};
|
||||||
|
for (const entry of raw.names) {
|
||||||
|
const lang = this.normalizeLang(entry.language);
|
||||||
|
const val = entry.value || entry.valuue || '';
|
||||||
|
if (val) {
|
||||||
|
if (!cat.translations[lang]) cat.translations[lang] = {};
|
||||||
|
cat.translations[lang].name = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: if top-level name is missing, use first available translation
|
||||||
|
if (!cat.name && raw.names.length > 0) {
|
||||||
|
const ruName = raw.names.find((n: any) => n.language === 'RU' || n.language === 'ru');
|
||||||
|
cat.name = ruName?.value || ruName?.valuue || raw.names[0].value || raw.names[0].valuue || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cat.name = cat.name || '';
|
||||||
|
|
||||||
if (raw.subcategories && Array.isArray(raw.subcategories)) {
|
if (raw.subcategories && Array.isArray(raw.subcategories)) {
|
||||||
cat.subcategories = raw.subcategories;
|
cat.subcategories = raw.subcategories;
|
||||||
@@ -185,15 +275,41 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getItem(itemID: number): Observable<Item> {
|
getItem(itemID: number): Observable<Item> {
|
||||||
return this.http.get<any>(`${this.baseUrl}/item/${itemID}`)
|
return this.http.get<any>(`${this.baseUrl}/items/${itemID}`)
|
||||||
.pipe(retry(this.retryConfig), map(item => this.normalizeItem(item)));
|
.pipe(retry(this.retryConfig), map(item => this.normalizeItem(item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> {
|
searchItems(
|
||||||
const params = new HttpParams()
|
search: string,
|
||||||
|
count: number = 50,
|
||||||
|
skip: number = 0,
|
||||||
|
options?: {
|
||||||
|
categoryIDs?: number[];
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
tag?: string;
|
||||||
|
sort?: 'relevance' | 'price_asc' | 'price_desc' | 'popular' | 'rating';
|
||||||
|
}
|
||||||
|
): Observable<{ items: Item[], total: number }> {
|
||||||
|
let params = new HttpParams()
|
||||||
.set('search', search)
|
.set('search', search)
|
||||||
.set('count', count.toString())
|
.set('count', count.toString())
|
||||||
.set('skip', skip.toString());
|
.set('skip', skip.toString());
|
||||||
|
if (options?.categoryIDs?.length) {
|
||||||
|
params = params.set('categoryIDs', options.categoryIDs.join(','));
|
||||||
|
}
|
||||||
|
if (options?.minPrice != null) {
|
||||||
|
params = params.set('minPrice', options.minPrice.toString());
|
||||||
|
}
|
||||||
|
if (options?.maxPrice != null) {
|
||||||
|
params = params.set('maxPrice', options.maxPrice.toString());
|
||||||
|
}
|
||||||
|
if (options?.tag) {
|
||||||
|
params = params.set('tag', options.tag);
|
||||||
|
}
|
||||||
|
if (options?.sort) {
|
||||||
|
params = params.set('sort', options.sort);
|
||||||
|
}
|
||||||
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
|
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
|
||||||
.pipe(
|
.pipe(
|
||||||
retry(this.retryConfig),
|
retry(this.retryConfig),
|
||||||
@@ -204,21 +320,9 @@ export class ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addToCart(itemID: number, quantity: number = 1): Observable<{ message: string }> {
|
// Cart operations — spec uses websession-based paths
|
||||||
return this.http.post<{ message: string }>(`${this.baseUrl}/cart`, { itemID, quantity });
|
addToCart(sessionId: string, items: Array<{ itemID: number; quantity: number; colour?: string; size?: string; price?: number }>): Observable<any> {
|
||||||
}
|
return this.http.post<any>(`${this.baseUrl}/websession/${sessionId}`, items);
|
||||||
|
|
||||||
updateCartQuantity(itemID: number, quantity: number): Observable<{ message: string }> {
|
|
||||||
return this.http.patch<{ message: string }>(`${this.baseUrl}/cart`, { itemID, quantity });
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFromCart(itemIDs: number[]): Observable<{ message: string }> {
|
|
||||||
return this.http.delete<{ message: string }>(`${this.baseUrl}/cart`, { body: itemIDs });
|
|
||||||
}
|
|
||||||
|
|
||||||
getCart(): Observable<Item[]> {
|
|
||||||
return this.http.get<any[]>(`${this.baseUrl}/cart`)
|
|
||||||
.pipe(map(items => this.normalizeItems(items)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Review submission
|
// Review submission
|
||||||
@@ -226,39 +330,42 @@ export class ApiService {
|
|||||||
itemID: number;
|
itemID: number;
|
||||||
rating: number;
|
rating: number;
|
||||||
comment: string;
|
comment: string;
|
||||||
username: string | null;
|
sessionID: string;
|
||||||
userId: number | null;
|
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}): Observable<{ message: string }> {
|
}): Observable<{ message: string }> {
|
||||||
return this.http.post<{ message: string }>(`${this.baseUrl}/comment`, reviewData);
|
const { itemID, ...body } = reviewData;
|
||||||
|
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/callback`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment - SBP Integration
|
// Question submission — spec path has typo "questiion"
|
||||||
createPayment(paymentData: {
|
submitQuestion(questionData: {
|
||||||
amount: number;
|
itemID: number;
|
||||||
currency: string;
|
question: string;
|
||||||
siteuserID: string;
|
sessionID: string;
|
||||||
siteorderID: string;
|
timestamp: string;
|
||||||
redirectUrl: string;
|
}): Observable<{ message: string }> {
|
||||||
telegramUsername: string;
|
const { itemID, ...body } = questionData;
|
||||||
items: Array<{ itemID: number; price: number; name: string }>;
|
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/questiion`, body);
|
||||||
}): Observable<{
|
}
|
||||||
|
|
||||||
|
// Payment - SBP Integration via websession QR
|
||||||
|
createPayment(sessionId: string): Observable<{
|
||||||
qrId: string;
|
qrId: string;
|
||||||
qrStatus: string;
|
qrStatus: string;
|
||||||
qrExpirationDate: string;
|
qrExpirationDate: string;
|
||||||
payload: string;
|
Payload: string;
|
||||||
qrUrl: string;
|
qrUrl: string;
|
||||||
}> {
|
}> {
|
||||||
return this.http.post<{
|
return this.http.post<{
|
||||||
qrId: string;
|
qrId: string;
|
||||||
qrStatus: string;
|
qrStatus: string;
|
||||||
qrExpirationDate: string;
|
qrExpirationDate: string;
|
||||||
payload: string;
|
Payload: string;
|
||||||
qrUrl: string;
|
qrUrl: string;
|
||||||
}>(`${this.baseUrl}/cart`, paymentData);
|
}>(`${this.baseUrl}/websession/${sessionId}/qr`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
checkPaymentStatus(qrId: string): Observable<{
|
checkPaymentStatus(sessionId: string, qrId: string): Observable<{
|
||||||
additionalInfo: string;
|
additionalInfo: string;
|
||||||
paymentPurpose: string;
|
paymentPurpose: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -271,7 +378,6 @@ export class ApiService {
|
|||||||
transactionDate: string;
|
transactionDate: string;
|
||||||
transactionId: number;
|
transactionId: number;
|
||||||
qrExpirationDate: string;
|
qrExpirationDate: string;
|
||||||
phoneNumber: string;
|
|
||||||
}> {
|
}> {
|
||||||
return this.http.get<{
|
return this.http.get<{
|
||||||
additionalInfo: string;
|
additionalInfo: string;
|
||||||
@@ -286,8 +392,7 @@ export class ApiService {
|
|||||||
transactionDate: string;
|
transactionDate: string;
|
||||||
transactionId: number;
|
transactionId: number;
|
||||||
qrExpirationDate: string;
|
qrExpirationDate: string;
|
||||||
phoneNumber: string;
|
}>(`${this.baseUrl}/websession/${sessionId}/${qrId}`);
|
||||||
}>(`${this.baseUrl}/qr/payment/${qrId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
submitPurchaseEmail(emailData: {
|
submitPurchaseEmail(emailData: {
|
||||||
@@ -303,7 +408,7 @@ export class ApiService {
|
|||||||
if (categoryID) {
|
if (categoryID) {
|
||||||
params = params.set('category', categoryID.toString());
|
params = params.set('category', categoryID.toString());
|
||||||
}
|
}
|
||||||
return this.http.get<any[]>(`${this.baseUrl}/randomitems`, { params })
|
return this.http.get<any[]>(`${this.baseUrl}/items/randomitems`, { params })
|
||||||
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
|
.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 */
|
/** Generate the Telegram login URL for bot-based auth */
|
||||||
getTelegramLoginUrl(): string {
|
getTelegramLoginUrl(): string {
|
||||||
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'dexarmarket_bot';
|
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
||||||
const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`);
|
const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`);
|
||||||
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
|
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ export class SeoService {
|
|||||||
* Set Open Graph & Twitter Card meta tags for a product/item page.
|
* Set Open Graph & Twitter Card meta tags for a product/item page.
|
||||||
*/
|
*/
|
||||||
setItemMeta(item: Item): void {
|
setItemMeta(item: Item): void {
|
||||||
const price = item.discount > 0 ? getDiscountedPrice(item) : item.price;
|
const price = item.discount > 0 ? getDiscountedPrice(item) : (item.price ?? 0);
|
||||||
const imageUrl = this.resolveUrl(getMainImage(item));
|
const imageUrl = this.resolveUrl(getMainImage(item));
|
||||||
const itemUrl = `${this.siteUrl}/item/${item.itemID}`;
|
const itemUrl = `${this.siteUrl}/item/${item.itemID}`;
|
||||||
const description = this.truncate(this.stripHtml(item.description), 160);
|
const description = this.truncate(this.stripHtml(item.description || ''), 160);
|
||||||
const titleText = `${item.name} — ${this.siteName}`;
|
const titleText = `${item.name || 'Product'} — ${this.siteName}`;
|
||||||
|
|
||||||
this.title.setTitle(titleText);
|
this.title.setTitle(titleText);
|
||||||
this.setCanonical(itemUrl);
|
this.setCanonical(itemUrl);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Item } from '../models';
|
import { Item } from '../models';
|
||||||
|
import { Category } from '../models/category.model';
|
||||||
|
|
||||||
export function getDiscountedPrice(item: Item): number {
|
export function getDiscountedPrice(item: Item): number {
|
||||||
return item.price * (1 - (item.discount || 0) / 100);
|
return item.price * (1 - (item.discount || 0) / 100);
|
||||||
@@ -69,20 +70,23 @@ export function getTranslatedField(
|
|||||||
field: 'name' | 'simpleDescription',
|
field: 'name' | 'simpleDescription',
|
||||||
lang: string
|
lang: string
|
||||||
): string {
|
): string {
|
||||||
// 1. Check translations map (backOffice format)
|
// 1. Check translations map (already normalized to frontend codes)
|
||||||
const translation = item.translations?.[lang];
|
const translation = item.translations?.[lang];
|
||||||
if (translation && translation[field]) {
|
if (translation && translation[field]) {
|
||||||
return translation[field]!;
|
return translation[field]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check names[]/descriptions[] arrays (backend API format)
|
// 2. Check names[]/descriptions[] arrays (may have API codes: RU/EN/AM)
|
||||||
|
// Note: API has typo "valuue" in some responses — handle both
|
||||||
if (field === 'name' && item.names?.length) {
|
if (field === 'name' && item.names?.length) {
|
||||||
const entry = item.names.find(n => n.language === lang);
|
const entry = item.names.find(n => n.language === lang || n.language === lang.toUpperCase() || (lang === 'hy' && n.language === 'AM'));
|
||||||
if (entry) return entry.value;
|
const val = entry?.value || (entry as any)?.valuue || '';
|
||||||
|
if (val) return val;
|
||||||
}
|
}
|
||||||
if (field === 'simpleDescription' && item.descriptions?.length) {
|
if (field === 'simpleDescription' && item.descriptions?.length) {
|
||||||
const entry = item.descriptions.find(d => d.language === lang);
|
const entry = item.descriptions.find(d => d.language === lang || d.language === lang.toUpperCase() || (lang === 'hy' && d.language === 'AM'));
|
||||||
if (entry) return entry.value;
|
const val = entry?.value || (entry as any)?.valuue || '';
|
||||||
|
if (val) return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fallback to base field
|
// 3. Fallback to base field
|
||||||
@@ -90,3 +94,19 @@ export function getTranslatedField(
|
|||||||
if (field === 'simpleDescription') return item.simpleDescription || item.description || '';
|
if (field === 'simpleDescription') return item.simpleDescription || item.description || '';
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get translated category name for the current language.
|
||||||
|
*/
|
||||||
|
export function getTranslatedCategoryName(cat: Category, lang: string): string {
|
||||||
|
const translation = cat.translations?.[lang];
|
||||||
|
if (translation?.name) return translation.name;
|
||||||
|
|
||||||
|
if (cat.names?.length) {
|
||||||
|
const entry = cat.names.find(n => n.language === lang || n.language === lang.toUpperCase() || (lang === 'hy' && n.language === 'AM'));
|
||||||
|
const val = entry?.value || (entry as any)?.valuue || '';
|
||||||
|
if (val) return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cat.name || '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const environment = {
|
|||||||
supportEmail: 'info@dexarmarket.ru',
|
supportEmail: 'info@dexarmarket.ru',
|
||||||
domain: 'dexarmarket.ru',
|
domain: 'dexarmarket.ru',
|
||||||
telegram: '@dexarmarket',
|
telegram: '@dexarmarket',
|
||||||
telegramBot: 'dexarmarket_bot',
|
telegramBot: 'DexarSupport_bot',
|
||||||
phones: {
|
phones: {
|
||||||
russia: '+7 (926) 459-31-57',
|
russia: '+7 (926) 459-31-57',
|
||||||
armenia: '+374 94 86 18 16'
|
armenia: '+374 94 86 18 16'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const environment = {
|
|||||||
supportEmail: 'info@dexarmarket.ru',
|
supportEmail: 'info@dexarmarket.ru',
|
||||||
domain: 'dexarmarket.ru',
|
domain: 'dexarmarket.ru',
|
||||||
telegram: '@dexarmarket',
|
telegram: '@dexarmarket',
|
||||||
telegramBot: 'dexarmarket_bot',
|
telegramBot: 'DexarSupport_bot',
|
||||||
phones: {
|
phones: {
|
||||||
russia: '+7 (926) 459-31-57',
|
russia: '+7 (926) 459-31-57',
|
||||||
armenia: '+374 94 86 18 16'
|
armenia: '+374 94 86 18 16'
|
||||||
|
|||||||
Reference in New Issue
Block a user