import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, timer } from 'rxjs'; import { catchError, map, retry } from 'rxjs/operators'; import { Category, Item, Subcategory } from '../models'; import { environment } from '../../environments/environment'; type PaymentBackend = 'websession' | 'qr' | 'cart'; interface PaymentRequestItem { itemID: number; price: number; name: string; quantity?: number; } export interface PaymentRequest { amount: number; currency: string; siteuserID: string; siteorderID: string; redirectUrl: string; telegramUsername: string; items: PaymentRequestItem[]; } export interface PaymentCreateResponse { qrId?: string; qrID?: string; qrStatus?: string; qrExpirationDate?: string; payload?: string; Payload?: string; qrUrl?: string; partnerID?: string | number; partnerId?: string | number; PartnerID?: string | number; } export interface PaymentCreateResult { backend: PaymentBackend; response: PaymentCreateResponse; } export interface PaymentStatusResponse { additionalInfo: string; paymentPurpose: string; amount: number; code: string; createDate: string; currency: string; order: string; paymentStatus: string; qrId: string; transactionDate: string; transactionId: number; qrExpirationDate: string; } @Injectable({ providedIn: 'root' }) export class ApiService { private readonly baseUrl = environment.apiUrl; private readonly retryConfig = { count: 2, delay: (error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500) }; constructor(private http: HttpClient) {} /** Map API language codes (RU/EN/AM) → frontend codes (ru/en/hy) */ private normalizeLang(apiLang: string): string { const map: Record = { 'RU': 'ru', 'EN': 'en', 'AM': 'hy' }; return map[apiLang] || apiLang.toLowerCase(); } /** Convert Go-style hex colour (0xfffca0) → CSS hex (#fffca0) */ private normalizeColor(c: string): string { if (!c) return ''; return c.startsWith('0x') ? '#' + c.slice(2) : c; } /** Resolve relative image URLs (e.g. ./images/x.webp) against site origin */ private resolveImageUrl(url: string): string { if (!url) return ''; if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return url; const origin = `https://${environment.domain}`; if (url.startsWith('./')) return `${origin}/${url.slice(2)}`; return `${origin}/${url}`; } /** * Normalize an item from the API response — supports both * legacy marketplace format and the new backOffice API format. */ private normalizeItem(raw: any): Item { const { partnerID, ...rest } = raw; const item: Item = { ...rest }; // Extract price/currency/remaining/colour/size from itemDetails[] // Note: Go struct tag is "itemdetails" but actual API may send "itemDetails" const details = raw.itemDetails || raw.itemdetails; if (details && Array.isArray(details) && details.length > 0) { const detail = details[0]; item.itemDetails = details.map((d: any) => ({ ...d, colour: this.normalizeColor(d.colour || d.color || ''), color: undefined, })); if (item.price == null || item.price === 0) item.price = detail.price; if (!item.currency) item.currency = detail.currency; if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || ''); if (!item.size) item.size = detail.size || ''; // Use remaining from detail for stock level if (raw.remaining == null && detail.remaining != null) { (raw as any).remaining = detail.remaining; } } // Map backOffice string id → legacy numeric itemID if (raw.id != null && raw.itemID == null) { item.id = String(raw.id); item.itemID = typeof raw.id === 'number' ? raw.id : 0; } // Map backOffice imgs[] → legacy photos[] if (raw.imgs && (!raw.photos || raw.photos.length === 0)) { 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?.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)) { item.descriptionFields = raw.description; item.description = raw.description.map((d: any) => `${d.key}: ${d.value}`).join('\n'); } else { item.description = raw.description || raw.simpleDescription || ''; } // 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) { 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 || ''; } } // Preserve attributes from backend item.attributes = raw.attributes || []; // Preserve colour & size (only if not already set from itemDetails) if (!item.colour) item.colour = this.normalizeColor(raw.colour || ''); if (!item.size) item.size = raw.size || ''; // Map backOffice comments → legacy callbacks if (raw.comments && (!raw.callbacks || raw.callbacks.length === 0)) { item.callbacks = raw.comments.map((c: any) => ({ rating: c.stars, content: c.text, userID: c.author, timestamp: c.createdAt, })); } item.comments = raw.comments || raw.callbacks?.map((c: any) => ({ id: c.userID, text: c.content, author: c.userID, stars: c.rating, createdAt: c.timestamp, })) || []; // Compute average rating from comments if not present if (raw.rating == null && item.comments && item.comments.length > 0) { const rated = item.comments.filter(c => c.stars != null); item.rating = rated.length > 0 ? rated.reduce((sum, c) => sum + (c.stars || 0), 0) / rated.length : 0; } 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.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'; // Preserve new backOffice fields item.badges = raw.badges || []; item.tags = raw.tags || []; item.simpleDescription = raw.simpleDescription || ''; 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; } private normalizeItems(items: any[] | null | undefined): Item[] { if (!items || !Array.isArray(items)) { return []; } return items.map(item => this.normalizeItem(item)); } /** * Normalize a category from the API response — supports both * the flat legacy format and nested backOffice format. */ private normalizeCategory(raw: any): Category { const cat: Category = { ...raw }; if (raw.id != null && raw.categoryID == null) { cat.id = String(raw.id); cat.categoryID = typeof raw.id === 'number' ? raw.id : 0; } // Map backOffice img → legacy icon if (raw.img && !raw.icon) { cat.icon = raw.img; } 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; } return cat; } private normalizeCategories(cats: any[] | null | undefined): Category[] { if (!cats || !Array.isArray(cats)) return []; return cats.map(c => this.normalizeCategory(c)); } // ─── Core Marketplace Endpoints ─────────────────────────── ping(): Observable<{ message: string }> { return this.http.get<{ message: string }>(`${this.baseUrl}/ping`); } getCategories(): Observable { return this.http.get(`${this.baseUrl}/category`) .pipe(retry(this.retryConfig), map(cats => this.normalizeCategories(cats))); } getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable { const params = new HttpParams() .set('count', count.toString()) .set('skip', skip.toString()); return this.http.get(`${this.baseUrl}/category/${categoryID}`, { params }) .pipe(retry(this.retryConfig), map(items => this.normalizeItems(items))); } getItem(itemID: number): Observable { return this.http.get(`${this.baseUrl}/items/${itemID}`) .pipe(retry(this.retryConfig), map(item => this.normalizeItem(item))); } 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(`${this.baseUrl}/searchitems`, { params }) .pipe( retry(this.retryConfig), map(response => ({ items: this.normalizeItems(response?.items || []), total: response?.total || 0 })) ); } // Cart operations — spec uses websession-based paths addToCart(sessionId: string, items: Array<{ itemID: number; quantity: number; colour?: string; size?: string; price?: number }>): Observable { return this.http.post(`${this.baseUrl}/websession/${sessionId}`, items); } // Review submission submitReview(reviewData: { itemID: number; rating: number; comment: string; sessionID: string; timestamp: string; }): Observable<{ message: string }> { const { itemID, ...body } = reviewData; return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/callback`, body); } // 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); } createPayment(paymentData: PaymentRequest, sessionId?: string): Observable { const directQrPayment$ = this.http.post(`${this.baseUrl}/qr`, paymentData).pipe( map(response => ({ backend: 'qr' as const, response })) ); const legacyCartPayment$ = this.http.post(`${this.baseUrl}/cart`, paymentData).pipe( map(response => ({ backend: 'cart' as const, response })) ); const directPayment$ = directQrPayment$.pipe( catchError(() => legacyCartPayment$) ); if (!sessionId) { return directPayment$; } return this.http.post(`${this.baseUrl}/websession/${sessionId}/qr`, {}).pipe( map(response => ({ backend: 'websession' as const, response })), catchError(() => directPayment$) ); } checkPaymentStatus(qrId: string, options?: { sessionId?: string; backend?: PaymentBackend }): Observable { const legacyStatus$ = this.http.get(`${this.baseUrl}/qr/payment/${qrId}`); if (options?.backend === 'websession' && options.sessionId) { return this.http.get(`${this.baseUrl}/websession/${options.sessionId}/${qrId}`).pipe( catchError(() => legacyStatus$) ); } if (options?.backend === 'qr' || options?.backend === 'cart') { return legacyStatus$; } if (options?.sessionId) { return this.http.get(`${this.baseUrl}/websession/${options.sessionId}/${qrId}`).pipe( catchError(() => legacyStatus$) ); } return legacyStatus$; } resolvePaymentQrId(response: PaymentCreateResponse): string { return response.qrId ?? response.qrID ?? ''; } resolvePaymentQrUrl(response: PaymentCreateResponse): string { if (response.qrUrl) { return response.qrUrl; } const qrId = this.resolvePaymentQrId(response); const partnerId = response.partnerID ?? response.partnerId ?? response.PartnerID; if (!qrId) { return ''; } if (partnerId != null) { return `${this.baseUrl}/qr/dynamic/${encodeURIComponent(String(partnerId))}/${encodeURIComponent(qrId)}`; } return `${this.baseUrl}/qr/static/${encodeURIComponent(qrId)}`; } resolvePaymentLink(response: PaymentCreateResponse): string { return response.payload ?? response.Payload ?? this.resolvePaymentQrUrl(response); } submitPurchaseEmail(emailData: { email: string; telegramUserId: string | null; items: Array<{ itemID: number; name: string; price: number; currency: string }>; }): Observable<{ message: string }> { return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData); } getRandomItems(count: number = 5, categoryID?: number): Observable { let params = new HttpParams().set('count', count.toString()); if (categoryID) { params = params.set('category', categoryID.toString()); } return this.http.get(`${this.baseUrl}/items/randomitems`, { params }) .pipe(retry(this.retryConfig), map(items => this.normalizeItems(items))); } }