This commit is contained in:
sdarbinyan
2026-03-24 02:25:50 +04:00
parent 97214c3a90
commit 650bf137f2
18 changed files with 1036 additions and 164 deletions

View File

@@ -18,6 +18,20 @@ export class ApiService {
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
* legacy marketplace format and the new backOffice API format.
@@ -26,6 +40,22 @@ export class ApiService {
const { partnerID, ...rest } = raw;
const item: Item = { ...rest };
// Extract price/currency/remaining/colour/size from itemDetails[]
// Note: Go struct tag is "itemdetails" but actual API may send "itemDetails"
const details = raw.itemDetails || raw.itemdetails;
if (details && Array.isArray(details) && details.length > 0) {
const detail = details[0];
item.itemDetails = 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
if (raw.id != null && raw.itemID == null) {
item.id = String(raw.id);
@@ -37,13 +67,16 @@ export class ApiService {
item.photos = raw.imgs.map((url: string) => ({ url }));
}
// Normalize photo type: API sends type='video'|'photo', template checks .video
// Also resolve relative URLs (e.g. ./images/x.webp) against API base
if (item.photos) {
item.photos = item.photos.map((p: any) => ({
...p,
url: this.resolveImageUrl(p.url),
video: p.video || (p.type === 'video' ? p.url : undefined),
}));
}
item.imgs = raw.imgs || raw.photos?.map((p: any) => p.url) || [];
item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u))
|| item.photos?.map((p: any) => p.url) || [];
// Map backOffice description (key-value array) → legacy description string
if (Array.isArray(raw.description)) {
@@ -54,12 +87,22 @@ export class ApiService {
}
// Map backend names[] → translations (multi-lang name support)
// Note: API has typo "valuue" in some responses, handle both
if (raw.names && Array.isArray(raw.names)) {
item.names = raw.names;
if (!item.translations) item.translations = {};
for (const entry of raw.names) {
if (!item.translations[entry.language]) item.translations[entry.language] = {};
item.translations[entry.language].name = entry.value;
const lang = this.normalizeLang(entry.language);
const val = entry.value || entry.valuue || '';
if (val) {
if (!item.translations[lang]) item.translations[lang] = {};
item.translations[lang].name = val;
}
}
// 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;
if (!item.translations) item.translations = {};
for (const entry of raw.descriptions) {
if (!item.translations[entry.language]) item.translations[entry.language] = {};
item.translations[entry.language].simpleDescription = entry.value;
const lang = this.normalizeLang(entry.language);
if (!item.translations[lang]) item.translations[lang] = {};
item.translations[lang].simpleDescription = entry.value;
}
}
// Preserve attributes from backend
item.attributes = raw.attributes || [];
// Preserve colour & size
item.colour = raw.colour || '';
item.size = raw.size || '';
// Preserve colour & size (only if not already set from itemDetails)
if (!item.colour) item.colour = raw.colour || '';
if (!item.size) item.size = raw.size || '';
// Map backOffice comments → legacy callbacks
if (raw.comments && (!raw.callbacks || raw.callbacks.length === 0)) {
@@ -107,8 +151,12 @@ export class ApiService {
item.rating = item.rating || 0;
// Defaults
item.name = item.name || '';
item.price = item.price ?? 0;
item.discount = item.discount || 0;
item.remainings = item.remainings || (raw.quantity != null
item.remainings = item.remainings || (raw.remaining != null
? (raw.remaining <= 0 ? 'out' : raw.remaining <= 5 ? 'low' : raw.remaining <= 20 ? 'medium' : 'high')
: raw.quantity != null
? (raw.quantity <= 0 ? 'out' : raw.quantity <= 5 ? 'low' : raw.quantity <= 20 ? 'medium' : 'high')
: 'high');
item.currency = item.currency || 'RUB';
@@ -120,6 +168,16 @@ export class ApiService {
item.translations = item.translations || raw.translations || {};
item.visible = raw.visible ?? true;
item.priority = raw.priority ?? 0;
item.visits = raw.visits ?? 0;
// Map question like/dislike → upvotes/downvotes
if (item.questions) {
item.questions = item.questions.map((q: any) => ({
...q,
upvotes: q.upvotes ?? q.like ?? 0,
downvotes: q.downvotes ?? q.dislike ?? 0,
}));
}
return item;
}
@@ -149,9 +207,41 @@ export class ApiService {
}
cat.img = raw.img || raw.icon;
// Resolve relative icon/image URLs
if (cat.icon) cat.icon = this.resolveImageUrl(cat.icon);
if (cat.img) cat.img = this.resolveImageUrl(cat.img);
// Map backend wideicon → wideBanner
if (raw.wideicon && !cat.wideBanner) {
cat.wideBanner = raw.wideicon;
}
cat.parentID = raw.parentID ?? 0;
cat.visible = raw.visible ?? true;
cat.priority = raw.priority ?? 0;
cat.itemCount = raw.itemCount ?? raw.ItemsCount ?? 0;
cat.categoriesCount = raw.categoriesCount ?? raw.CategoriesCount ?? 0;
// Map backend names[] → translations (multi-lang name support)
// Note: API has typo "valuue" in some responses, handle both
if (raw.names && Array.isArray(raw.names)) {
cat.names = raw.names;
cat.translations = cat.translations || {};
for (const entry of raw.names) {
const lang = this.normalizeLang(entry.language);
const val = entry.value || entry.valuue || '';
if (val) {
if (!cat.translations[lang]) cat.translations[lang] = {};
cat.translations[lang].name = val;
}
}
// Fallback: if top-level name is missing, use first available translation
if (!cat.name && raw.names.length > 0) {
const ruName = raw.names.find((n: any) => n.language === 'RU' || n.language === 'ru');
cat.name = ruName?.value || ruName?.valuue || raw.names[0].value || raw.names[0].valuue || '';
}
}
cat.name = cat.name || '';
if (raw.subcategories && Array.isArray(raw.subcategories)) {
cat.subcategories = raw.subcategories;
@@ -185,15 +275,41 @@ export class ApiService {
}
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)));
}
searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> {
const params = new HttpParams()
searchItems(
search: string,
count: number = 50,
skip: number = 0,
options?: {
categoryIDs?: number[];
minPrice?: number;
maxPrice?: number;
tag?: string;
sort?: 'relevance' | 'price_asc' | 'price_desc' | 'popular' | 'rating';
}
): Observable<{ items: Item[], total: number }> {
let params = new HttpParams()
.set('search', search)
.set('count', count.toString())
.set('skip', skip.toString());
if (options?.categoryIDs?.length) {
params = params.set('categoryIDs', options.categoryIDs.join(','));
}
if (options?.minPrice != null) {
params = params.set('minPrice', options.minPrice.toString());
}
if (options?.maxPrice != null) {
params = params.set('maxPrice', options.maxPrice.toString());
}
if (options?.tag) {
params = params.set('tag', options.tag);
}
if (options?.sort) {
params = params.set('sort', options.sort);
}
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
.pipe(
retry(this.retryConfig),
@@ -204,21 +320,9 @@ export class ApiService {
);
}
addToCart(itemID: number, quantity: number = 1): Observable<{ message: string }> {
return this.http.post<{ message: string }>(`${this.baseUrl}/cart`, { itemID, quantity });
}
updateCartQuantity(itemID: number, quantity: number): Observable<{ message: string }> {
return this.http.patch<{ message: string }>(`${this.baseUrl}/cart`, { itemID, quantity });
}
removeFromCart(itemIDs: number[]): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.baseUrl}/cart`, { body: itemIDs });
}
getCart(): Observable<Item[]> {
return this.http.get<any[]>(`${this.baseUrl}/cart`)
.pipe(map(items => this.normalizeItems(items)));
// Cart operations — spec uses websession-based paths
addToCart(sessionId: string, items: Array<{ itemID: number; quantity: number; colour?: string; size?: string; price?: number }>): Observable<any> {
return this.http.post<any>(`${this.baseUrl}/websession/${sessionId}`, items);
}
// Review submission
@@ -226,39 +330,42 @@ export class ApiService {
itemID: number;
rating: number;
comment: string;
username: string | null;
userId: number | null;
sessionID: string;
timestamp: string;
}): Observable<{ message: string }> {
return this.http.post<{ message: string }>(`${this.baseUrl}/comment`, reviewData);
const { itemID, ...body } = reviewData;
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/callback`, body);
}
// Payment - SBP Integration
createPayment(paymentData: {
amount: number;
currency: string;
siteuserID: string;
siteorderID: string;
redirectUrl: string;
telegramUsername: string;
items: Array<{ itemID: number; price: number; name: string }>;
}): Observable<{
// Question submission — spec path has typo "questiion"
submitQuestion(questionData: {
itemID: number;
question: string;
sessionID: string;
timestamp: string;
}): Observable<{ message: string }> {
const { itemID, ...body } = questionData;
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/questiion`, body);
}
// Payment - SBP Integration via websession QR
createPayment(sessionId: string): Observable<{
qrId: string;
qrStatus: string;
qrExpirationDate: string;
payload: string;
Payload: string;
qrUrl: string;
}> {
return this.http.post<{
qrId: string;
qrStatus: string;
qrExpirationDate: string;
payload: string;
Payload: string;
qrUrl: string;
}>(`${this.baseUrl}/cart`, paymentData);
}>(`${this.baseUrl}/websession/${sessionId}/qr`, {});
}
checkPaymentStatus(qrId: string): Observable<{
checkPaymentStatus(sessionId: string, qrId: string): Observable<{
additionalInfo: string;
paymentPurpose: string;
amount: number;
@@ -271,7 +378,6 @@ export class ApiService {
transactionDate: string;
transactionId: number;
qrExpirationDate: string;
phoneNumber: string;
}> {
return this.http.get<{
additionalInfo: string;
@@ -286,8 +392,7 @@ export class ApiService {
transactionDate: string;
transactionId: number;
qrExpirationDate: string;
phoneNumber: string;
}>(`${this.baseUrl}/qr/payment/${qrId}`);
}>(`${this.baseUrl}/websession/${sessionId}/${qrId}`);
}
submitPurchaseEmail(emailData: {
@@ -303,7 +408,7 @@ export class ApiService {
if (categoryID) {
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)));
}
}

View File

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

View File

@@ -19,11 +19,11 @@ export class SeoService {
* Set Open Graph & Twitter Card meta tags for a product/item page.
*/
setItemMeta(item: Item): void {
const price = item.discount > 0 ? getDiscountedPrice(item) : item.price;
const price = item.discount > 0 ? getDiscountedPrice(item) : (item.price ?? 0);
const imageUrl = this.resolveUrl(getMainImage(item));
const itemUrl = `${this.siteUrl}/item/${item.itemID}`;
const description = this.truncate(this.stripHtml(item.description), 160);
const titleText = `${item.name}${this.siteName}`;
const description = this.truncate(this.stripHtml(item.description || ''), 160);
const titleText = `${item.name || 'Product'}${this.siteName}`;
this.title.setTitle(titleText);
this.setCanonical(itemUrl);