2 Commits

Author SHA1 Message Date
sdarbinyan
5ed255dddb Merge branch 'main' of https://sources.vitanova.network/sdarbinyan/marketplaces 2026-03-24 02:27:59 +04:00
sdarbinyan
650bf137f2 fixes 2026-03-24 02:25:50 +04:00
18 changed files with 1036 additions and 164 deletions

View File

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

View File

@@ -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,

View File

@@ -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);
} }

View File

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

View File

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

View File

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

View File

@@ -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);

View File

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

View File

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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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()
}; };

View File

@@ -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)));
} }
} }

View File

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

View File

@@ -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);

View File

@@ -1,4 +1,5 @@
import { Item } from '../models'; import { Item } from '../models';
import { Category } from '../models/category.model';
export function getDiscountedPrice(item: Item): number { export function getDiscountedPrice(item: Item): number {
return item.price * (1 - (item.discount || 0) / 100); return item.price * (1 - (item.discount || 0) / 100);
@@ -69,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 || '';
}

View File

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

View File

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