/** * **Сервис советника Barneo Search Widget** * * _Предоставляет методы для взаимодействия с API рекомендаций Ensi Cloud Adviser._ * * @module AdviserService */ import type { RecommendationProductsRequest, RecommendationQueryProductsRequest, CrossSellProductsRequest, UpSellProductsRequest, RecentlyWatchedProductsRequest, PopularProductsSearchRequest, SimilarProductsSearchRequest, } from "../types/requests"; import type { RecommendationResponse, PopularProductsResponse, SimilarProductsResponse, } from "../types/responses"; import { AdviserBlockType, AdviserSortType, IncludeType } from "../types/enums"; /** * **Конфигурация сервиса советника** */ export interface AdviserServiceConfig { /** Базовый URL для API */ baseUrl: string; /** Токен аутентификации */ token: string; /** ID клиента */ customerId: string; /** ID локации */ locationId: string; /** Версия API */ apiVersion?: string; /** Лимит записей для API запросов */ apiLimit?: number; } /** * **Сервис для работы с API рекомендаций Ensi Cloud Adviser** */ export class AdviserService { private readonly baseUrl: string; private readonly token: string; private readonly customerId: string; private readonly locationId: string; private readonly apiVersion: string | undefined; private readonly apiLimit: number; private readonly defaultHeaders: Record; constructor(config: AdviserServiceConfig) { this.baseUrl = config.baseUrl.replace(/\/$/, ""); // Убираем trailing slash this.token = config.token; this.customerId = config.customerId; this.locationId = config.locationId; this.apiVersion = config.apiVersion; // Может быть undefined this.apiLimit = config.apiLimit || 5; // По умолчанию 5 // Инициализируем заголовки по умолчанию this.defaultHeaders = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${this.token}`, "X-Customer-Id": this.customerId, }; } /** * Формирует полный URL для API запроса */ private buildApiUrl(endpoint: string): string { // Убираем ведущий слеш из endpoint если есть const cleanEndpoint = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; // Если apiVersion не указан, используем только baseUrl if (!this.apiVersion) { const url = `${this.baseUrl}/${cleanEndpoint}`; return url; } // Если apiVersion указан, добавляем его в путь const url = `${this.baseUrl}/${this.apiVersion}/${cleanEndpoint}`; return url; } /** * Выполнить HTTP запрос к API */ private async makeRequest( endpoint: string, options: RequestInit = {} ): Promise { const url = this.buildApiUrl(endpoint); const requestOptions: RequestInit = { ...options, headers: { ...this.defaultHeaders, ...options.headers, }, }; try { const response = await fetch(url, requestOptions); if (!response.ok) { await this.handleErrorResponse(response, endpoint); } const data = await response.json(); return data; } catch (error) { this.handleRequestError(error, endpoint); throw error; } } /** * Обработка ошибок HTTP ответа */ private async handleErrorResponse( response: Response, endpoint: string ): Promise { const status = response.status; let errorMessage: string; switch (status) { case 401: errorMessage = `Ошибка авторизации (401): Проверьте правильность токена API и customer ID`; break; case 403: errorMessage = `Доступ запрещен (403): Недостаточно прав для выполнения операции`; break; case 404: errorMessage = `Эндпоинт не найден (404): ${endpoint}`; break; case 429: errorMessage = `Превышен лимит запросов (429): Попробуйте позже`; break; case 500: errorMessage = `Внутренняя ошибка сервера (500): Попробуйте позже`; break; default: const errorText = await response.text(); let errorData; try { errorData = JSON.parse(errorText); } catch { errorData = { message: errorText }; } errorMessage = `API Error: ${status} - ${ errorData.message || response.statusText }`; } const error = new Error(errorMessage); (error as any).status = status; (error as any).endpoint = endpoint; throw error; } /** * Обработка ошибок сети */ private handleRequestError(error: unknown, endpoint: string): void { console.error(`API Request Error for ${endpoint}:`, error); if (error instanceof TypeError && error.message.includes("fetch")) { throw new Error( `Ошибка сети: Не удается подключиться к API (${endpoint})` ); } } /** * Получить рекомендации "People also search for this product" */ async getRecommendationProducts( productId: string, request?: Partial> ): Promise { const defaultRequest: RecommendationProductsRequest = { filter: { product_id: productId, }, pagination: { limit: this.apiLimit, }, load_full_products: { location_id: this.locationId, include: [IncludeType.PROPERTIES], }, }; const finalRequest = { ...defaultRequest, ...request }; return this.makeRequest( "adviser/recommendation-products:search", { method: "POST", body: JSON.stringify(finalRequest), } ); } /** * Получить рекомендации "Searchers also viewed" */ async getRecommendationQueryProducts( query: string, request?: Partial> ): Promise { const defaultRequest: RecommendationQueryProductsRequest = { filter: { query: query, }, pagination: { limit: this.apiLimit, }, load_full_products: { location_id: this.locationId, include: [IncludeType.PROPERTIES], }, }; const finalRequest = { ...defaultRequest, ...request }; return this.makeRequest( "adviser/recommendation-query-products:search", { method: "POST", body: JSON.stringify(finalRequest), } ); } /** * Получить рекомендации "Cross Sell" (С этим товаром покупают) */ async getCrossSellProducts( productId: string, request?: Partial> ): Promise { const defaultRequest: CrossSellProductsRequest = { filter: { product_id: productId, }, pagination: { limit: this.apiLimit, }, load_full_products: { location_id: this.locationId, include: [IncludeType.PROPERTIES], }, }; const finalRequest = { ...defaultRequest, ...request }; return this.makeRequest( "adviser/cross-sell-products:search", { method: "POST", body: JSON.stringify(finalRequest), } ); } /** * Получить рекомендации "Up Sell" (более дорогие альтернативы) */ async getUpSellProducts( productId: string, request?: Partial> ): Promise { const defaultRequest: UpSellProductsRequest = { filter: { product_id: productId, location_id: this.locationId, }, pagination: { limit: this.apiLimit, }, load_full_products: { location_id: this.locationId, include: [IncludeType.PROPERTIES], }, }; const finalRequest = { ...defaultRequest, ...request }; return this.makeRequest( "adviser/up-sell-products:search", { method: "POST", body: JSON.stringify(finalRequest), } ); } /** * Получить рекомендации "Up Sell" (новая версия) */ async getUpSellProductsNew( productId: string, request?: Partial> ): Promise { const defaultRequest: UpSellProductsRequest = { filter: { product_id: productId, location_id: this.locationId, }, pagination: { limit: this.apiLimit, }, load_full_products: { location_id: this.locationId, include: [IncludeType.PROPERTIES], }, }; const finalRequest = { ...defaultRequest, ...request }; return this.makeRequest( "adviser/up-sell-products:search-new", { method: "POST", body: JSON.stringify(finalRequest), } ); } /** * Получить недавно просмотренные товары */ async getRecentlyWatchedProducts( customerId: string, request?: Partial> ): Promise { const defaultRequest: RecentlyWatchedProductsRequest = { filter: { customer_id: customerId, }, pagination: { limit: this.apiLimit, }, load_full_products: { location_id: this.locationId, include: [IncludeType.PROPERTIES], }, }; const finalRequest = { ...defaultRequest, ...request }; return this.makeRequest( "adviser/recently-watched-products:search", { method: "POST", body: JSON.stringify(finalRequest), } ); } /** * Получить популярные товары */ async getPopularProducts( request?: Partial ): Promise { const defaultRequest: PopularProductsSearchRequest = { include: ["automatic"], pagination: { limit_automatic: this.apiLimit, }, load_full_products: { location_id: this.locationId, include: [IncludeType.PROPERTIES], }, }; const finalRequest = { ...defaultRequest, ...request }; return this.makeRequest( "analytics/popular-products:search", { method: "POST", body: JSON.stringify(finalRequest), } ); } /** * Получить похожие товары */ async getSimilarProducts( request?: Partial ): Promise { const defaultRequest: SimilarProductsSearchRequest = { filter: { parameter: 1, product_id: "", location_id: this.locationId, }, pagination: { limit: this.apiLimit, }, load_full_products: { location_id: this.locationId, include: [IncludeType.PROPERTIES], }, }; const finalRequest = { ...defaultRequest, ...request }; return this.makeRequest( "analytics/similar-products:search", { method: "POST", body: JSON.stringify(finalRequest), } ); } /** * Получить информацию о конфигурации (без чувствительных данных) */ getConfigInfo(): { baseUrl: string; customerId: string; locationId: string; apiVersion: string | undefined; apiLimit: number; } { return { baseUrl: this.baseUrl, customerId: this.customerId, locationId: this.locationId, apiVersion: this.apiVersion, apiLimit: this.apiLimit, }; } /** * Создать новый экземпляр сервиса с обновленной конфигурацией */ withConfig(updates: Partial): AdviserService { const currentConfig = { baseUrl: this.baseUrl, token: this.token, customerId: this.customerId, locationId: this.locationId, apiVersion: this.apiVersion, apiLimit: this.apiLimit, }; return new AdviserService({ ...currentConfig, ...updates, }); } }