diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 334a312..7d95137 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -4,6 +4,7 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; import { cacheInterceptor } from './interceptors/cache.interceptor'; +import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor'; import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor'; import { mockDataInterceptor } from './interceptors/mock-data.interceptor'; import { provideServiceWorker } from '@angular/service-worker'; @@ -17,7 +18,7 @@ export const appConfig: ApplicationConfig = { withInMemoryScrolling({ scrollPositionRestoration: 'top' }) ), provideHttpClient( - withInterceptors([mockDataInterceptor, apiHeadersInterceptor, cacheInterceptor]) + withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, cacheInterceptor]) ), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), diff --git a/src/app/app.ts b/src/app/app.ts index 4d75cb8..5bc17a6 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -2,19 +2,18 @@ import { Component, OnInit, signal, ApplicationRef, inject, DestroyRef } from '@angular/core'; import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { Title } from '@angular/platform-browser'; -import { HttpClient } from '@angular/common/http'; import { HeaderComponent } from './components/header/header.component'; import { FooterComponent } from './components/footer/footer.component'; import { BackButtonComponent } from './components/back-button/back-button.component'; import { interval, concat } from 'rxjs'; import { filter, first } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { environment } from '../environments/environment'; import { SwUpdate } from '@angular/service-worker'; import { TranslatePipe } from './i18n/translate.pipe'; import { TranslateService } from './i18n/translate.service'; import { PlatformRuntimeService } from './core/runtime/platform-runtime.service'; import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade'; +import { ApiHealthService } from './services/api-health.service'; @Component({ selector: 'app-root', @@ -29,7 +28,6 @@ export class App implements OnInit { serverAvailable = signal(false); private destroyRef = inject(DestroyRef); - private http = inject(HttpClient); private titleService = inject(Title); private swUpdate = inject(SwUpdate); private appRef = inject(ApplicationRef); @@ -37,6 +35,7 @@ export class App implements OnInit { private i18n = inject(TranslateService); private platformRuntime = inject(PlatformRuntimeService); private uiRuntime = inject(UiRuntimeFacade); + private apiHealth = inject(ApiHealthService); ngOnInit(): void { this.platformRuntime.initialize(); @@ -61,7 +60,7 @@ export class App implements OnInit { private checkServerHealth(): void { this.checkingServer.set(true); - this.http.get<{ message: string }>(`${environment.apiUrl}/ping`) + this.apiHealth.ping() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { diff --git a/src/app/core/categories/repositories/api-category.repository.ts b/src/app/core/categories/repositories/api-category.repository.ts index ce4987d..0e4b3b2 100644 --- a/src/app/core/categories/repositories/api-category.repository.ts +++ b/src/app/core/categories/repositories/api-category.repository.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable, timer } from 'rxjs'; import { retry } from 'rxjs/operators'; -import { environment } from '../../../../environments/environment'; +import { ApiConfigService } from '../../config/api-config.service'; import { CategoryDto } from '../dto/category.dto'; import { CategoryRepository } from './category.repository'; @@ -13,10 +13,13 @@ export class ApiCategoryRepository implements CategoryRepository { delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500) }; - constructor(private readonly http: HttpClient) {} + constructor( + private readonly http: HttpClient, + private readonly apiConfig: ApiConfigService + ) {} getCategories(): Observable { - return this.http.get(`${environment.apiUrl}/category`) + return this.http.get(`${this.apiConfig.getBaseUrl()}/category`) .pipe(retry(this.retryConfig)); } } \ No newline at end of file diff --git a/src/app/core/config/api-config.service.ts b/src/app/core/config/api-config.service.ts new file mode 100644 index 0000000..40bdc6a --- /dev/null +++ b/src/app/core/config/api-config.service.ts @@ -0,0 +1,86 @@ +import { Injectable, inject } from '@angular/core'; +import { environment } from '../../../environments/environment'; +import { ConfigService } from './config.service'; +import { TenantResolverService } from './tenant-resolver.service'; + +@Injectable({ providedIn: 'root' }) +export class ApiConfigService { + private readonly tenantResolver = inject(TenantResolverService); + private readonly configService = inject(ConfigService); + + getBaseUrl(): string { + const tenantKey = this.tenantResolver.getTenantKey(); + const bootstrapUrl = this.resolveBootstrapApiBaseUrl(); + const tenantMap = (environment as any).tenantApiBaseUrls as Record | undefined; + const localhostUrl = (environment as any).localhostApiUrl as string | undefined; + const apiTemplate = (environment as any).tenantApiTemplate as string | undefined; + + let url = environment.apiUrl; + + if (this.tenantResolver.isLocalhost() && localhostUrl) { + url = localhostUrl; + } else if (bootstrapUrl) { + url = bootstrapUrl; + } else if (tenantMap?.[tenantKey]) { + url = tenantMap[tenantKey]; + } else if (apiTemplate) { + url = apiTemplate.replace('{tenant}', tenantKey); + } + + return this.normalizeBaseUrl(url); + } + + isApiRequest(url: string): boolean { + if (!url) { + return false; + } + + if (url.startsWith('/api')) { + return true; + } + + const baseUrl = this.getBaseUrl(); + return url.startsWith(baseUrl); + } + + toApiUrl(url: string): string { + if (!url || /^https?:\/\//i.test(url)) { + return url; + } + + if (!url.startsWith('/api')) { + return url; + } + + const baseUrl = this.getBaseUrl(); + const path = url.slice('/api'.length); + return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`; + } + + private resolveBootstrapApiBaseUrl(): string | null { + const bootstrap = this.configService.getBootstrapSnapshot() as any; + if (!bootstrap) { + return null; + } + + const endpointBase = bootstrap?.apiEndpoints?.website?.baseUrl; + if (typeof endpointBase === 'string' && endpointBase.trim().length > 0) { + return endpointBase; + } + + const tenantBase = bootstrap?.tenant?.apiBaseUrl; + if (typeof tenantBase === 'string' && tenantBase.trim().length > 0) { + return tenantBase; + } + + return null; + } + + private normalizeBaseUrl(url: string): string { + if (!url || url === '/') { + return '/'; + } + + return url.replace(/\/+$/, ''); + } +} \ No newline at end of file diff --git a/src/app/core/config/tenant-resolver.service.ts b/src/app/core/config/tenant-resolver.service.ts new file mode 100644 index 0000000..dec81aa --- /dev/null +++ b/src/app/core/config/tenant-resolver.service.ts @@ -0,0 +1,36 @@ +import { DOCUMENT } from '@angular/common'; +import { Injectable, inject } from '@angular/core'; +import { environment } from '../../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class TenantResolverService { + private readonly document = inject(DOCUMENT); + + getHostname(): string { + const host = this.document?.location?.hostname ?? ''; + return host.toLowerCase(); + } + + isLocalhost(): boolean { + const hostname = this.getHostname(); + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; + } + + getTenantKey(): string { + if (this.isLocalhost()) { + return (environment as any).fallbackTenantKey ?? 'default'; + } + + const hostname = this.getHostname(); + const segments = hostname.split('.').filter(Boolean); + if (segments.length === 0) { + return (environment as any).fallbackTenantKey ?? 'default'; + } + + if (segments[0] === 'www' && segments.length > 1) { + return segments[1]; + } + + return segments[0]; + } +} \ No newline at end of file diff --git a/src/app/interceptors/api-base-url.interceptor.ts b/src/app/interceptors/api-base-url.interceptor.ts new file mode 100644 index 0000000..6164309 --- /dev/null +++ b/src/app/interceptors/api-base-url.interceptor.ts @@ -0,0 +1,18 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { ApiConfigService } from '../core/config/api-config.service'; + +export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => { + const apiConfig = inject(ApiConfigService); + + if (!req.url.startsWith('/api')) { + return next(req); + } + + const resolvedUrl = apiConfig.toApiUrl(req.url); + if (resolvedUrl === req.url) { + return next(req); + } + + return next(req.clone({ url: resolvedUrl })); +}; \ No newline at end of file diff --git a/src/app/interceptors/api-headers.interceptor.ts b/src/app/interceptors/api-headers.interceptor.ts index 764acf6..c7d9a9c 100644 --- a/src/app/interceptors/api-headers.interceptor.ts +++ b/src/app/interceptors/api-headers.interceptor.ts @@ -1,9 +1,9 @@ import { HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; +import { ApiConfigService } from '../core/config/api-config.service'; import { LocationService } from '../services/location.service'; import { LanguageService } from '../services/language.service'; import { AuthService } from '../services/auth.service'; -import { environment } from '../../environments/environment'; /** Map internal language codes to API header values */ const LANG_HEADER_MAP: Record = { @@ -39,7 +39,8 @@ function getAnonymousSessionId(): string { } export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => { - if (!req.url.startsWith(environment.apiUrl)) { + const apiConfig = inject(ApiConfigService); + if (!apiConfig.isApiRequest(req.url)) { return next(req); } diff --git a/src/app/services/api-health.service.ts b/src/app/services/api-health.service.ts new file mode 100644 index 0000000..0926621 --- /dev/null +++ b/src/app/services/api-health.service.ts @@ -0,0 +1,14 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../core/config/api-config.service'; + +@Injectable({ providedIn: 'root' }) +export class ApiHealthService { + private readonly http = inject(HttpClient); + private readonly apiConfig = inject(ApiConfigService); + + ping(): Observable<{ message: string }> { + return this.http.get<{ message: string }>(`${this.apiConfig.getBaseUrl()}/ping`); + } +} \ No newline at end of file diff --git a/src/app/services/api.service.ts b/src/app/services/api.service.ts index 4a06e89..345d300 100644 --- a/src/app/services/api.service.ts +++ b/src/app/services/api.service.ts @@ -5,6 +5,7 @@ import { map, retry } from 'rxjs/operators'; import { Category, DeliveryOption, Item, Subcategory } from '../models'; import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils'; import { environment } from '../../environments/environment'; +import { ApiConfigService } from '../core/config/api-config.service'; export interface QrCreateRequest { qrtype: 'QRDynamic'; @@ -68,7 +69,6 @@ export interface QrDynamicStatusResponse { providedIn: 'root' }) export class ApiService { - private readonly baseUrl = environment.apiUrl; private readonly qrBaseUrl = (environment as any).qrApiUrl as string; private readonly cartPaymentPartnerId = 'web-97ec-9c57-4dde-9037-3a68f7f83750'; @@ -77,7 +77,14 @@ export class ApiService { delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500) }; - constructor(private http: HttpClient) {} + constructor( + private readonly http: HttpClient, + private readonly apiConfig: ApiConfigService + ) {} + + private get baseUrl(): string { + return this.apiConfig.getBaseUrl(); + } /** Map API language codes (RU/EN/AM) → frontend codes (ru/en/hy) */ private normalizeLang(apiLang: string): string { diff --git a/src/app/services/location.service.ts b/src/app/services/location.service.ts index 59a8780..aca3c3c 100644 --- a/src/app/services/location.service.ts +++ b/src/app/services/location.service.ts @@ -1,7 +1,7 @@ import { Injectable, signal, computed } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Region, GeoIpResponse } from '../models/location.model'; -import { environment } from '../../environments/environment'; +import { ApiConfigService } from '../core/config/api-config.service'; const STORAGE_KEY = 'selected_region'; @@ -26,16 +26,17 @@ export class LocationService { /** Computed region id for API calls — empty string means global */ readonly regionId = computed(() => this.regionSignal()?.id ?? ''); - private readonly apiUrl = environment.apiUrl; - - constructor(private http: HttpClient) { + constructor( + private readonly http: HttpClient, + private readonly apiConfig: ApiConfigService + ) { this.loadRegions(); this.restoreFromStorage(); } /** Fetch available regions from backend */ loadRegions(): void { - this.http.get(`${this.apiUrl}/regions`).subscribe({ + this.http.get(`${this.apiConfig.getBaseUrl()}/regions`).subscribe({ next: (regions) => { this.regionsSignal.set(regions); // If we have a stored region, validate it still exists diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts index 3fc149e..0475399 100644 --- a/src/environments/environment.production.ts +++ b/src/environments/environment.production.ts @@ -1,6 +1,13 @@ // Marketplace Production Configuration export const environment = { production: true, + fallbackTenantKey: 'default', + localhostApiUrl: 'https://api.dexarmarket.ru:445', + tenantApiTemplate: 'https://{tenant}.api.dexarmarket.ru:445', + tenantApiBaseUrls: { + default: 'https://api.dexarmarket.ru:445', + dexarmarket: 'https://api.dexarmarket.ru:445' + }, brandName: 'Marketplace', brandFullName: 'Marketplace', theme: 'dexar', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index a438469..b1ac189 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -2,6 +2,13 @@ export const environment = { production: false, useMockData: false, // Toggle to test with backOffice mock data + fallbackTenantKey: 'default', + localhostApiUrl: 'https://api.dexarmarket.ru:445', + tenantApiTemplate: 'https://{tenant}.api.dexarmarket.ru:445', + tenantApiBaseUrls: { + default: 'https://api.dexarmarket.ru:445', + dexarmarket: 'https://api.dexarmarket.ru:445' + }, brandName: 'Marketplace', brandFullName: 'Marketplace', theme: 'dexar',