/** * **Сервис аналитики Barneo Search Widget** * * _Предоставляет методы для отправки аналитических данных в API._ * * @module analytics-service */ import type { AuthorizeCustomerRequest, QueryUseRequest, FunctionalUseRequest, HintsUseRequest, ProductsUseRequest, CategoriesUseRequest, AnalyticsApiResponse, } from "../types/requests"; import type { AuthorizeCustomerResponse, QueryUseResponse, FunctionalUseResponse, HintsUseResponse, ProductsUseResponse, CategoriesUseResponse, } from "../types/responses"; import { SearchFunction, ProductConversionSource, ProductConversionType, CategoryConversionSource, CategoryConversionType, } from "../types/enums"; /** * **Конфигурация сервиса аналитики** */ export interface AnalyticsServiceConfig { /** Базовый URL для API */ baseUrl: string; /** Токен аутентификации */ token: string; /** ID клиента */ customerId: string; /** ID локации */ locationId: string; /** Версия API */ apiVersion?: string; } /** * **Сервис для работы с аналитическими API** */ export class AnalyticsService { private readonly baseUrl: string; private readonly token: string; private readonly customerId: string; private readonly locationId: string; private readonly apiVersion: string | undefined; private readonly defaultHeaders: Record; constructor(config: AnalyticsServiceConfig) { 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.defaultHeaders = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${this.token}`, }; } /** * Формирует полный URL для API запроса */ private buildApiUrl(endpoint: string): string { // Убираем ведущий слеш из endpoint если есть const cleanEndpoint = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; // Если apiVersion не указан, используем только baseUrl if (!this.apiVersion) { return `${this.baseUrl}/${cleanEndpoint}`; } // Если apiVersion указан, добавляем его в путь return `${this.baseUrl}/${this.apiVersion}/${cleanEndpoint}`; } /** * Выполнить 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 400: errorMessage = `Ошибка запроса (400): Неверные параметры для ${endpoint}`; break; case 401: errorMessage = `Ошибка авторизации (401): Проверьте правильность токена API`; 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(`Analytics API Request Error for ${endpoint}:`, error); if (error instanceof TypeError && error.message.includes("fetch")) { throw new Error( `Ошибка сети: Не удается подключиться к API (${endpoint})` ); } } /** * Объединение неавторизованного и авторизованного пользователя */ async authorizeCustomer( request: AuthorizeCustomerRequest ): Promise> { this.validateAuthorizeCustomerRequest(request); return this.makeRequest("customers/authorize", { method: "POST", body: JSON.stringify(request), }); } /** * Отслеживание поискового запроса клиента */ async trackQueryUse( query: string ): Promise> { if (!query?.trim()) { throw new Error("Поисковый запрос не может быть пустым"); } const request: QueryUseRequest = { location_id: this.locationId, customer_id: this.customerId, query: query.trim(), }; return this.makeRequest("analytics/query:use", { method: "POST", body: JSON.stringify(request), }); } /** * Отслеживание использования функции поиска */ async trackFunctionUse( functionName: SearchFunction ): Promise> { const request: FunctionalUseRequest = { location_id: this.locationId, customer_id: this.customerId, function: functionName, }; return this.makeRequest("analytics/functional:use", { method: "POST", body: JSON.stringify(request), }); } /** * Отслеживание применения поисковой подсказки */ async trackHintUse( query: string, hint: string ): Promise> { if (!query?.trim() || !hint?.trim()) { throw new Error("Запрос и подсказка не могут быть пустыми"); } const request: HintsUseRequest = { query: query.trim(), hint: hint.trim(), }; return this.makeRequest("analytics/hints:use", { method: "POST", body: JSON.stringify(request), }); } /** * Отслеживание конверсии товара */ async trackProductConversion( productId: string, conversion: ProductConversionType, options?: { source?: ProductConversionSource; position?: number; query?: string; } ): Promise> { if (!productId?.trim()) { throw new Error("ID товара не может быть пустым"); } const request: ProductsUseRequest = { location_id: this.locationId, customer_id: this.customerId, product_id: productId.trim(), conversion, ...options, }; return this.makeRequest("analytics/products:use", { method: "POST", body: JSON.stringify(request), }); } /** * Отслеживание конверсии категории */ async trackCategoryConversion( categoryId: string, options?: { query?: string; source?: CategoryConversionSource; } ): Promise> { if (!categoryId?.trim()) { throw new Error("ID категории не может быть пустым"); } const request: CategoriesUseRequest = { location_id: this.locationId, category_id: categoryId.trim(), customer_id: this.customerId, conversion: CategoryConversionType.CLICK, ...options, }; return this.makeRequest("analytics/categories:use", { method: "POST", body: JSON.stringify(request), }); } /** * Валидация запроса авторизации клиента */ private validateAuthorizeCustomerRequest( request: AuthorizeCustomerRequest ): void { if (!request.guest_customer_id || !request.authorized_customer_id) { throw new Error( "Необходимо указать guest_customer_id и authorized_customer_id" ); } } /** * Получить ID локации */ getLocationId(): string { return this.locationId; } /** * Получить ID клиента */ getCustomerId(): string { return this.customerId; } /** * Получить информацию о конфигурации (без чувствительных данных) */ getConfigInfo(): { baseUrl: string; customerId: string; locationId: string; apiVersion: string | undefined; } { return { baseUrl: this.baseUrl, customerId: this.customerId, locationId: this.locationId, apiVersion: this.apiVersion, }; } /** * Создать новый экземпляр сервиса с обновленной конфигурацией */ withConfig(updates: Partial): AnalyticsService { const currentConfig = { baseUrl: this.baseUrl, token: this.token, customerId: this.customerId, locationId: this.locationId, apiVersion: this.apiVersion, }; return new AnalyticsService({ ...currentConfig, ...updates, }); } }