Files
marketplaces/src/app/services/api.service.ts

717 lines
26 KiB
TypeScript
Raw Normal View History

2026-01-18 18:57:06 +04:00
import { Injectable } from '@angular/core';
2026-06-05 18:23:24 +04:00
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
2026-03-06 18:40:58 +04:00
import { Observable, timer } from 'rxjs';
2026-06-02 01:46:12 +04:00
import { map, retry } from 'rxjs/operators';
2026-06-22 01:45:23 +04:00
import { Category, DeliveryOption, Item, Subcategory } from '../models';
2026-06-21 23:42:39 +04:00
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
2026-01-18 18:57:06 +04:00
import { environment } from '../../environments/environment';
import { ApiConfigService } from '../core/config/api-config.service';
2026-01-18 18:57:06 +04:00
2026-06-05 18:23:24 +04:00
export interface QrCreateRequest {
qrtype: 'QRDynamic';
2026-06-02 00:57:36 +04:00
amount: number;
2026-06-05 18:23:24 +04:00
currency: 'RUB';
2026-06-06 16:16:37 +04:00
partnerqrID?: string;
2026-06-05 18:23:24 +04:00
qrDescription?: string;
Userid?: string;
Reference?: string;
RedirectUrl?: string;
2026-06-02 00:57:36 +04:00
}
2026-06-02 01:46:12 +04:00
export interface QrCreateResponse {
2026-06-02 00:57:36 +04:00
qrId?: string;
qrID?: string;
2026-06-02 01:46:12 +04:00
nspkID?: string;
nspkId?: string;
nspkurl?: string;
2026-06-29 00:06:18 +04:00
orderID?: string;
url?: string;
2026-06-28 22:18:35 +04:00
bankUrl?: string;
2026-06-02 01:46:12 +04:00
status?: string;
2026-06-02 00:57:36 +04:00
qrStatus?: string;
qrExpirationDate?: string;
qrTTL?: number;
2026-06-02 00:57:36 +04:00
payload?: string;
Payload?: string;
qrUrl?: string;
2026-06-02 01:46:12 +04:00
partnerqrID?: string | number;
2026-06-02 00:57:36 +04:00
partnerID?: string | number;
partnerId?: string | number;
PartnerID?: string | number;
}
2026-06-06 22:38:01 +04:00
export interface CartPaymentRequest {
amount: number;
currency: string;
2026-06-06 22:38:01 +04:00
siteuserID: string;
siteorderID: string;
redirectUrl: string;
telegramUsername: string;
2026-06-29 00:06:18 +04:00
paymentMethod: 'qr' | 'card';
qrDescription?: string;
customerID?: string;
2026-06-22 10:46:51 +04:00
items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[] }>;
2026-06-06 22:38:01 +04:00
}
2026-07-20 01:02:36 +04:00
export interface CreateOrderRequest {
/**
* No `price` field: the backend must price each line item from its own
* catalog by `productId`, never trust a client-supplied amount.
* See BACKEND-API-REFERENCE.md §12.
*/
items: Array<{ productId: string; name: string; quantity: number }>;
2026-07-20 01:02:36 +04:00
customer: { name: string; email: string; phone: string };
payment?: { method: string; currency: string };
shipping?: { address: string; method: string; trackingNumber: string };
}
export interface CreateOrderResponse {
id: string;
orderNumber: string;
status: string;
total: number;
currency: string;
}
2026-06-05 18:23:24 +04:00
export interface QrDynamicStatusResponse {
2026-06-02 00:57:36 +04:00
additionalInfo: string;
paymentPurpose: string;
amount: number;
code: string;
createDate: string;
currency: string;
order: string;
2026-06-18 13:11:05 +04:00
status: string;
2026-06-02 00:57:36 +04:00
qrId: string;
transactionDate: string;
transactionId: number;
qrExpirationDate: string;
}
2026-01-18 18:57:06 +04:00
@Injectable({
providedIn: 'root'
})
export class ApiService {
2026-06-05 18:23:24 +04:00
private readonly qrBaseUrl = (environment as any).qrApiUrl as string;
2026-06-06 22:38:01 +04:00
private readonly cartPaymentPartnerId = 'web-97ec-9c57-4dde-9037-3a68f7f83750';
2026-01-18 18:57:06 +04:00
2026-03-06 18:40:58 +04:00
private readonly retryConfig = {
count: 2,
2026-06-21 23:42:39 +04:00
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
2026-03-06 18:40:58 +04:00
};
constructor(
private readonly http: HttpClient,
private readonly apiConfig: ApiConfigService
) {}
private get baseUrl(): string {
return this.apiConfig.getBaseUrl();
}
2026-01-18 18:57:06 +04:00
2026-03-24 02:25:50 +04:00
/** 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();
}
2026-03-24 03:12:04 +04:00
/** 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;
}
private normalizeMediaType(type: unknown): 'image' | 'video' | 'pdf' | 'manual' | 'warranty' {
const value = String(type ?? '').toLowerCase();
if (value === 'video') return 'video';
if (value === 'pdf') return 'pdf';
if (value === 'manual') return 'manual';
if (value === 'warranty') return 'warranty';
return 'image';
}
2026-06-21 23:13:01 +04:00
private normalizeDeliveryData(
raw: any,
legacyDeliveryPrice?: number
): { options: DeliveryOption[]; isDigital: boolean; requiresSelection: boolean } {
const rawDelivery = raw.delivery ?? raw.deliveries;
if (typeof rawDelivery === 'string' && rawDelivery.trim().toLowerCase() === 'digital') {
return { options: [], isDigital: true, requiresSelection: false };
}
const deliveryCandidates = Array.isArray(rawDelivery)
? rawDelivery
: rawDelivery != null
? [rawDelivery]
: [];
const options = deliveryCandidates
2026-06-21 23:42:39 +04:00
.map(candidate => normalizeDeliveryOption(candidate))
2026-06-21 23:13:01 +04:00
.filter((option): option is DeliveryOption => option !== null);
if (options.length > 0) {
return { options, isDigital: false, requiresSelection: rawDelivery != null };
}
if (legacyDeliveryPrice !== undefined) {
return {
options: [{ deliveryPrice: legacyDeliveryPrice, deliveryPlace: '', deliveryTime: '' }],
isDigital: false,
requiresSelection: false,
};
}
if (rawDelivery != null) {
return { options: [], isDigital: true, requiresSelection: false };
}
return { options: [], isDigital: false, requiresSelection: false };
}
2026-06-22 01:45:23 +04:00
private normalizeSubcategory(raw: any): Subcategory {
const subcategory: Subcategory = {
id: String(raw.id ?? raw.categoryId ?? raw.categoryID ?? ''),
name: typeof raw.name === 'string' ? raw.name : '',
visible: raw.visible ?? true,
priority: raw.priority ?? 0,
img: raw.img ? this.resolveImageUrl(raw.img) : undefined,
categoryId: String(raw.categoryId ?? raw.categoryID ?? raw.id ?? ''),
parentId: String(raw.parentId ?? raw.parentID ?? ''),
itemCount: raw.itemCount ?? raw.ItemsCount ?? 0,
hasItems: raw.hasItems,
subcategories: Array.isArray(raw.subcategories)
? raw.subcategories
.map((sub: any) => this.normalizeSubcategory(sub))
.filter((sub: Subcategory) => this.isDisplayableSubcategory(sub))
: [],
};
return subcategory;
}
private isDisplayableSubcategory(subcategory: Subcategory): boolean {
if (subcategory.visible === false) {
return false;
}
return (subcategory.itemCount ?? 0) > 0
|| subcategory.hasItems === true
|| (subcategory.subcategories?.length ?? 0) > 0;
}
private isDisplayableCategory(category: Category): boolean {
return category.visible !== false;
}
private isDisplayableItem(item: Item): boolean {
return item.visible !== false;
}
2026-03-24 02:46:58 +04:00
/** Resolve relative image URLs (e.g. ./images/x.webp) against site origin */
2026-03-24 02:25:50 +04:00
private resolveImageUrl(url: string): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return url;
const origin = typeof window !== 'undefined'
? window.location.origin
: '';
2026-03-24 02:46:58 +04:00
if (url.startsWith('./')) return `${origin}/${url.slice(2)}`;
return `${origin}/${url}`;
2026-03-24 02:25:50 +04:00
}
2026-02-20 10:44:03 +04:00
/**
* Normalize an item from the API response supports both
* legacy marketplace format and the new backOffice API format.
*/
private normalizeItem(raw: any): Item {
2026-03-24 00:09:11 +04:00
const { partnerID, ...rest } = raw;
const item: Item = { ...rest };
2026-06-21 23:42:39 +04:00
let legacyDeliveryPrice = normalizeOptionalNumber(
2026-06-20 15:16:25 +04:00
raw.deliveryPrice ?? raw.delivery_price ?? raw.deliveryprice
);
2026-03-24 02:25:50 +04:00
// 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];
2026-03-24 03:12:04 +04:00
item.itemDetails = details.map((d: any) => ({
...d,
colour: this.normalizeColor(d.colour || d.color || ''),
color: undefined,
2026-06-21 23:42:39 +04:00
deliveryPrice: normalizeOptionalNumber(
2026-06-20 15:16:25 +04:00
d.deliveryPrice ?? d.delivery_price ?? d.deliveryprice
),
2026-03-24 03:12:04 +04:00
}));
2026-03-24 02:25:50 +04:00
if (item.price == null || item.price === 0) item.price = detail.price;
if (!item.currency) item.currency = detail.currency;
2026-03-24 03:12:04 +04:00
if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || '');
2026-03-24 02:25:50 +04:00
if (!item.size) item.size = detail.size || '';
2026-06-21 23:13:01 +04:00
if (legacyDeliveryPrice === undefined) {
2026-06-21 23:42:39 +04:00
legacyDeliveryPrice = normalizeOptionalNumber(
2026-06-20 15:16:25 +04:00
detail.deliveryPrice ?? detail.delivery_price ?? detail.deliveryprice
);
}
2026-03-24 02:25:50 +04:00
// Use remaining from detail for stock level
if (raw.remaining == null && detail.remaining != null) {
(raw as any).remaining = detail.remaining;
}
}
2026-06-21 23:13:01 +04:00
const deliveryData = this.normalizeDeliveryData(raw, legacyDeliveryPrice);
if (deliveryData.options.length > 0) {
item.deliveryOptions = deliveryData.options;
item.deliveryMode = 'selectable';
item.deliverySelectionRequired = deliveryData.requiresSelection;
} else if (deliveryData.isDigital) {
item.deliveryMode = 'digital';
item.deliverySelectionRequired = false;
}
2026-03-24 02:25:50 +04:00
2026-02-20 10:44:03 +04:00
// 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 }));
}
2026-03-24 00:09:11 +04:00
// Normalize photo type: API sends type='video'|'photo', template checks .video
2026-03-24 02:25:50 +04:00
// Also resolve relative URLs (e.g. ./images/x.webp) against API base
2026-03-24 00:09:11 +04:00
if (item.photos) {
item.photos = item.photos.map((p: any) => ({
...p,
2026-03-24 02:25:50 +04:00
url: this.resolveImageUrl(p.url),
2026-03-24 00:09:11 +04:00
video: p.video || (p.type === 'video' ? p.url : undefined),
}));
}
2026-03-24 02:25:50 +04:00
item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u))
|| item.photos?.map((p: any) => p.url) || [];
2026-02-20 10:44:03 +04:00
const rawMedia = Array.isArray(raw.media) ? raw.media : [];
const mediaFromPhotos = (item.photos ?? []).map((photo: any, index: number) => ({
id: photo.id ?? `photo-${item.itemID}-${index}`,
type: this.normalizeMediaType(photo.type ?? (photo.video ? 'video' : 'image')),
url: this.resolveImageUrl(photo.url),
thumbnailUrl: photo.thumbnailUrl ? this.resolveImageUrl(photo.thumbnailUrl) : undefined,
alt: photo.alt,
title: photo.title,
labels: photo.labels
}));
item.media = (rawMedia.length > 0 ? rawMedia : mediaFromPhotos)
.map((entry: any, index: number) => ({
id: entry.id ?? `media-${item.itemID}-${index}`,
type: this.normalizeMediaType(entry.type),
url: this.resolveImageUrl(entry.url),
thumbnailUrl: entry.thumbnailUrl ? this.resolveImageUrl(entry.thumbnailUrl) : undefined,
alt: entry.alt,
title: entry.title,
labels: entry.labels,
}))
.filter((entry: any) => !!entry.url);
2026-02-20 10:44:03 +04:00
// 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 || '';
}
2026-03-24 00:09:11 +04:00
// Map backend names[] → translations (multi-lang name support)
2026-03-24 02:25:50 +04:00
// Note: API has typo "valuue" in some responses, handle both
2026-03-24 00:09:11 +04:00
if (raw.names && Array.isArray(raw.names)) {
item.names = raw.names;
if (!item.translations) item.translations = {};
for (const entry of raw.names) {
2026-03-24 02:25:50 +04:00
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 || '';
2026-03-24 00:09:11 +04:00
}
}
// Preserve attributes from backend
item.attributes = raw.attributes || [];
item.specificationGroups = Array.isArray(raw.specificationGroups)
? raw.specificationGroups.map((group: any, groupIndex: number) => ({
id: group.id ?? `group-${groupIndex}`,
key: group.key ?? `group-${groupIndex}`,
label: group.label,
labels: group.labels,
attributes: Array.isArray(group.attributes)
? group.attributes.map((attribute: any, attributeIndex: number) => ({
key: attribute.key ?? `attribute-${attributeIndex}`,
value: String(attribute.value ?? ''),
label: attribute.label,
labels: attribute.labels,
unit: attribute.unit,
}))
: []
}))
: [];
item.variantOptions = Array.isArray(raw.variantOptions)
? raw.variantOptions.map((group: any, groupIndex: number) => ({
key: group.key ?? `variant-${groupIndex}`,
label: group.label,
labels: group.labels,
options: Array.isArray(group.options)
? group.options.map((option: any) => ({
value: String(option.value ?? ''),
label: option.label,
labels: option.labels,
available: option.available !== false,
})).filter((option: any) => option.value.length > 0)
: []
})).filter((group: any) => group.options.length > 0)
: [];
item.relatedCollections = Array.isArray(raw.relatedCollections)
? raw.relatedCollections.map((collection: any, collectionIndex: number) => ({
id: String(collection.id ?? `related-${collectionIndex}`),
title: String(collection.title ?? ''),
titles: collection.titles,
products: Array.isArray(collection.products)
? collection.products.map((productId: any) => Number(productId)).filter((productId: number) => Number.isFinite(productId))
: []
}))
: [];
2026-03-24 00:09:11 +04:00
2026-03-24 02:25:50 +04:00
// Preserve colour & size (only if not already set from itemDetails)
2026-03-24 03:12:04 +04:00
if (!item.colour) item.colour = this.normalizeColor(raw.colour || '');
2026-03-24 02:25:50 +04:00
if (!item.size) item.size = raw.size || '';
2026-03-24 00:09:11 +04:00
2026-02-20 10:44:03 +04:00
// 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
2026-03-24 02:25:50 +04:00
item.name = item.name || '';
item.price = item.price ?? 0;
2026-02-20 10:44:03 +04:00
item.discount = item.discount || 0;
2026-03-24 02:25:50 +04:00
item.remainings = item.remainings || (raw.remaining != null
? (raw.remaining <= 0 ? 'out' : raw.remaining <= 5 ? 'low' : raw.remaining <= 20 ? 'medium' : 'high')
: raw.quantity != null
2026-02-20 10:44:03 +04:00
? (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 || '';
2026-03-24 00:09:11 +04:00
item.translations = item.translations || raw.translations || {};
2026-02-20 10:44:03 +04:00
item.visible = raw.visible ?? true;
item.priority = raw.priority ?? 0;
2026-03-24 02:25:50 +04:00
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,
}));
}
2026-02-20 10:44:03 +04:00
return item;
2026-01-18 18:57:06 +04:00
}
2026-02-20 10:44:03 +04:00
private normalizeItems(items: any[] | null | undefined): Item[] {
2026-01-18 18:57:06 +04:00
if (!items || !Array.isArray(items)) {
return [];
}
2026-06-22 01:45:23 +04:00
return items
.map(item => this.normalizeItem(item))
.filter(item => this.isDisplayableItem(item));
2026-01-18 18:57:06 +04:00
}
2026-02-20 10:44:03 +04:00
/**
* 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;
2026-03-24 02:25:50 +04:00
// 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;
}
2026-02-20 10:44:03 +04:00
cat.parentID = raw.parentID ?? 0;
cat.visible = raw.visible ?? true;
cat.priority = raw.priority ?? 0;
2026-03-24 02:25:50 +04:00
cat.itemCount = raw.itemCount ?? raw.ItemsCount ?? 0;
cat.categoriesCount = raw.categoriesCount ?? raw.CategoriesCount ?? 0;
2026-07-05 00:38:21 +04:00
cat.marketplaceId = raw.marketplaceId ?? raw.projectId;
2026-03-24 02:25:50 +04:00
// 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 || '';
2026-02-20 10:44:03 +04:00
if (raw.subcategories && Array.isArray(raw.subcategories)) {
2026-06-22 01:45:23 +04:00
cat.subcategories = raw.subcategories
.map((sub: any) => this.normalizeSubcategory(sub))
.filter((sub: Subcategory) => this.isDisplayableSubcategory(sub));
2026-02-20 10:44:03 +04:00
}
return cat;
}
private normalizeCategories(cats: any[] | null | undefined): Category[] {
if (!cats || !Array.isArray(cats)) return [];
2026-06-22 01:45:23 +04:00
return cats
.map(c => this.normalizeCategory(c))
.filter(category => this.isDisplayableCategory(category));
2026-02-20 10:44:03 +04:00
}
// ─── Core Marketplace Endpoints ───────────────────────────
2026-01-18 18:57:06 +04:00
ping(): Observable<{ message: string }> {
return this.http.get<{ message: string }>(`${this.baseUrl}/ping`);
}
getCategories(): Observable<Category[]> {
2026-02-20 10:44:03 +04:00
return this.http.get<any[]>(`${this.baseUrl}/category`)
.pipe(retry(this.retryConfig), map(cats => this.normalizeCategories(cats)));
2026-01-18 18:57:06 +04:00
}
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
const params = new HttpParams()
.set('count', count.toString())
.set('skip', skip.toString());
2026-02-20 10:44:03 +04:00
return this.http.get<any[]>(`${this.baseUrl}/category/${categoryID}`, { params })
2026-03-06 18:40:58 +04:00
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
2026-01-18 18:57:06 +04:00
}
getItem(itemID: number): Observable<Item> {
2026-03-24 02:25:50 +04:00
return this.http.get<any>(`${this.baseUrl}/items/${itemID}`)
2026-03-06 18:40:58 +04:00
.pipe(retry(this.retryConfig), map(item => this.normalizeItem(item)));
2026-01-18 18:57:06 +04:00
}
2026-03-24 02:25:50 +04:00
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()
2026-01-18 18:57:06 +04:00
.set('search', search)
.set('count', count.toString())
.set('skip', skip.toString());
2026-03-24 02:25:50 +04:00
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);
}
2026-02-20 10:44:03 +04:00
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
2026-01-18 18:57:06 +04:00
.pipe(
2026-03-06 18:40:58 +04:00
retry(this.retryConfig),
2026-01-18 18:57:06 +04:00
map(response => ({
items: this.normalizeItems(response?.items || []),
total: response?.total || 0
}))
);
}
2026-03-24 02:25:50 +04:00
// 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);
2026-01-18 18:57:06 +04:00
}
// Review submission
submitReview(reviewData: {
itemID: number;
rating: number;
comment: string;
2026-03-24 02:25:50 +04:00
sessionID: string;
2026-01-18 18:57:06 +04:00
timestamp: string;
}): Observable<{ message: string }> {
2026-03-24 02:25:50 +04:00
const { itemID, ...body } = reviewData;
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/callback`, body);
2026-01-18 18:57:06 +04:00
}
2026-03-24 02:25:50 +04:00
// 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);
}
2026-06-05 18:23:24 +04:00
createPayment(payload: QrCreateRequest, headers?: { authorizationKey?: string; userIdValue?: string }): Observable<QrCreateResponse> {
let httpHeaders = new HttpHeaders();
if (headers?.authorizationKey) {
httpHeaders = httpHeaders.set('authorization-key', headers.authorizationKey);
}
if (headers?.userIdValue) {
httpHeaders = httpHeaders.set('userid-value', headers.userIdValue);
}
return this.http.post<QrCreateResponse>(`${this.qrBaseUrl}/qr`, payload, { headers: httpHeaders });
2026-06-02 00:57:36 +04:00
}
2026-06-06 22:38:01 +04:00
createCartPayment(payload: CartPaymentRequest): Observable<QrCreateResponse> {
return this.http.post<QrCreateResponse>(`${this.baseUrl}/cart`, payload);
2026-06-06 16:16:37 +04:00
}
2026-07-20 01:02:36 +04:00
/**
* Records the just-paid cart as a backoffice order (POST /orders). Fire-and-forget
* from the caller's perspective - a failure here must never block the existing
* payment-confirmed flow, since payment itself is unaffected by this call.
*/
createOrder(payload: CreateOrderRequest): Observable<CreateOrderResponse> {
return this.http.post<CreateOrderResponse>(`${this.baseUrl}/orders`, payload);
}
2026-06-06 22:38:01 +04:00
checkCartPaymentStatus(qrId: string): Observable<QrDynamicStatusResponse> {
return this.http.get<QrDynamicStatusResponse>(
`${this.qrBaseUrl}/qr/dynamic/${this.cartPaymentPartnerId}/${encodeURIComponent(qrId)}`
);
2026-06-06 16:16:37 +04:00
}
2026-06-29 22:15:19 +04:00
checkCartCardPaymentStatus(orderId: string): Observable<QrDynamicStatusResponse> {
return this.http.get<QrDynamicStatusResponse>(
`${this.qrBaseUrl}/card/${this.cartPaymentPartnerId}/${encodeURIComponent(orderId)}`
);
}
2026-06-05 18:23:24 +04:00
checkPaymentStatus(partnerQrId: string, qrId: string): Observable<QrDynamicStatusResponse> {
return this.http.get<QrDynamicStatusResponse>(
`${this.qrBaseUrl}/qr/dynamic/${encodeURIComponent(partnerQrId)}/${encodeURIComponent(qrId)}`
);
2026-06-02 00:57:36 +04:00
}
2026-06-02 01:46:12 +04:00
resolvePaymentQrId(response: QrCreateResponse): string {
2026-06-29 00:06:18 +04:00
return response.qrId ?? response.qrID ?? response.nspkID ?? response.nspkId ?? response.orderID ?? '';
2026-06-02 01:46:12 +04:00
}
2026-06-02 00:57:36 +04:00
2026-06-02 01:46:12 +04:00
resolvePaymentLink(response: QrCreateResponse): string {
return response.nspkurl ?? response.Payload ?? response.payload ?? response.qrUrl ?? '';
}
2026-06-02 00:57:36 +04:00
2026-06-28 22:18:35 +04:00
resolveBankPaymentUrl(response: QrCreateResponse): string {
2026-06-29 00:06:18 +04:00
return response.bankUrl ?? response.url ?? '';
2026-06-28 22:18:35 +04:00
}
2026-06-02 01:46:12 +04:00
resolvePaymentQrUrl(response: QrCreateResponse): string {
const paymentLink = this.resolvePaymentLink(response);
if (paymentLink) {
return `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(paymentLink)}`;
2026-06-02 00:57:36 +04:00
}
2026-06-02 01:46:12 +04:00
return response.qrUrl ?? '';
2026-01-18 18:57:06 +04:00
}
submitPurchaseEmail(emailData: {
email: string;
2026-06-21 23:13:01 +04:00
phone?: string;
2026-01-18 18:57:06 +04:00
telegramUserId: string | null;
2026-06-22 10:46:51 +04:00
items: Array<{ itemID: number; name: string; price: number; currency: string; quantity?: number; delivery?: DeliveryOption[] }>;
2026-01-18 18:57:06 +04:00
}): Observable<{ message: string }> {
return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData);
}
/**
* Back-in-stock subscription. No backend endpoint exists for this yet
* (tracked in BACKEND-API-REFERENCE.md §12) - callers should fall back to
* a local-only record (see CartService/UX facade patterns) when this 404s
* or the request otherwise fails, rather than surfacing an error to the
* shopper for something this minor.
*/
subscribeToRestock(itemID: number, contact: { telegramUserId: string | null; email?: string }): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/items/${itemID}/notify-me`, contact);
}
2026-01-18 18:57:06 +04:00
getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> {
let params = new HttpParams().set('count', count.toString());
if (categoryID) {
params = params.set('category', categoryID.toString());
}
2026-03-24 02:58:51 +04:00
return this.http.get<any[]>(`${this.baseUrl}/items/randomitems`, { params })
2026-03-06 18:40:58 +04:00
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
2026-01-18 18:57:06 +04:00
}
}