2026-02-28 17:18:24 +04:00
|
|
|
import { Injectable, signal, computed } from '@angular/core';
|
|
|
|
|
import { HttpClient } from '@angular/common/http';
|
|
|
|
|
import { Region, GeoIpResponse } from '../models/location.model';
|
2026-07-05 02:24:16 +04:00
|
|
|
import { ApiConfigService } from '../core/config/api-config.service';
|
2026-07-15 02:58:39 +04:00
|
|
|
import { LocalStorageService } from '../core/storage/local-storage.service';
|
2026-02-28 17:18:24 +04:00
|
|
|
|
|
|
|
|
const STORAGE_KEY = 'selected_region';
|
|
|
|
|
|
|
|
|
|
@Injectable({
|
|
|
|
|
providedIn: 'root'
|
|
|
|
|
})
|
|
|
|
|
export class LocationService {
|
|
|
|
|
private regionSignal = signal<Region | null>(null);
|
|
|
|
|
private regionsSignal = signal<Region[]>([]);
|
|
|
|
|
private loadingSignal = signal(false);
|
|
|
|
|
private detectedSignal = signal(false);
|
|
|
|
|
|
|
|
|
|
/** Current selected region (null = global / all regions) */
|
|
|
|
|
readonly region = this.regionSignal.asReadonly();
|
|
|
|
|
/** All available regions */
|
|
|
|
|
readonly regions = this.regionsSignal.asReadonly();
|
|
|
|
|
/** Whether geo-detection is in progress */
|
|
|
|
|
readonly detecting = this.loadingSignal.asReadonly();
|
|
|
|
|
/** Whether region was auto-detected */
|
|
|
|
|
readonly autoDetected = this.detectedSignal.asReadonly();
|
|
|
|
|
|
|
|
|
|
/** Computed region id for API calls — empty string means global */
|
|
|
|
|
readonly regionId = computed(() => this.regionSignal()?.id ?? '');
|
|
|
|
|
|
2026-07-05 02:24:16 +04:00
|
|
|
constructor(
|
|
|
|
|
private readonly http: HttpClient,
|
2026-07-15 02:58:39 +04:00
|
|
|
private readonly apiConfig: ApiConfigService,
|
|
|
|
|
private readonly storage: LocalStorageService
|
2026-07-05 02:24:16 +04:00
|
|
|
) {
|
2026-02-28 17:18:24 +04:00
|
|
|
this.loadRegions();
|
|
|
|
|
this.restoreFromStorage();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Fetch available regions from backend */
|
|
|
|
|
loadRegions(): void {
|
2026-07-05 02:24:16 +04:00
|
|
|
this.http.get<Region[]>(`${this.apiConfig.getBaseUrl()}/regions`).subscribe({
|
2026-02-28 17:18:24 +04:00
|
|
|
next: (regions) => {
|
|
|
|
|
this.regionsSignal.set(regions);
|
|
|
|
|
// If we have a stored region, validate it still exists
|
|
|
|
|
const stored = this.regionSignal();
|
|
|
|
|
if (stored && !regions.find(r => r.id === stored.id)) {
|
|
|
|
|
this.clearRegion();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
error: () => {
|
|
|
|
|
// Fallback: hardcoded popular regions
|
|
|
|
|
this.regionsSignal.set(this.getFallbackRegions());
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Set region by user choice */
|
|
|
|
|
setRegion(region: Region): void {
|
|
|
|
|
this.regionSignal.set(region);
|
2026-07-15 02:58:39 +04:00
|
|
|
this.storage.setJSON(STORAGE_KEY, region);
|
2026-02-28 17:18:24 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Clear region (go global) */
|
|
|
|
|
clearRegion(): void {
|
|
|
|
|
this.regionSignal.set(null);
|
2026-07-15 02:58:39 +04:00
|
|
|
this.storage.removeItem(STORAGE_KEY);
|
2026-02-28 17:18:24 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Auto-detect user location via IP geolocation */
|
|
|
|
|
detectLocation(): void {
|
|
|
|
|
if (this.detectedSignal()) return; // already tried
|
|
|
|
|
this.loadingSignal.set(true);
|
|
|
|
|
|
|
|
|
|
// Using free ip-api.com — no key required, 45 req/min
|
|
|
|
|
this.http.get<GeoIpResponse>('http://ip-api.com/json/?fields=city,country,countryCode,region,timezone,lat,lon')
|
|
|
|
|
.subscribe({
|
|
|
|
|
next: (geo) => {
|
|
|
|
|
this.detectedSignal.set(true);
|
|
|
|
|
this.loadingSignal.set(false);
|
|
|
|
|
|
|
|
|
|
// Only auto-set if user hasn't manually chosen a region
|
|
|
|
|
if (!this.regionSignal()) {
|
|
|
|
|
const matchedRegion = this.findRegionByGeo(geo);
|
|
|
|
|
if (matchedRegion) {
|
|
|
|
|
this.setRegion(matchedRegion);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
error: () => {
|
|
|
|
|
this.detectedSignal.set(true);
|
|
|
|
|
this.loadingSignal.set(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Try to match detected geo data to an available region */
|
|
|
|
|
private findRegionByGeo(geo: GeoIpResponse): Region | null {
|
|
|
|
|
const regions = this.regionsSignal();
|
|
|
|
|
if (!regions.length) return null;
|
|
|
|
|
|
|
|
|
|
// Exact city match
|
|
|
|
|
const cityMatch = regions.find(r =>
|
|
|
|
|
r.city.toLowerCase() === geo.city?.toLowerCase()
|
|
|
|
|
);
|
|
|
|
|
if (cityMatch) return cityMatch;
|
|
|
|
|
|
|
|
|
|
// Country match — pick the first region for that country
|
|
|
|
|
const countryMatch = regions.find(r =>
|
|
|
|
|
r.countryCode.toLowerCase() === geo.countryCode?.toLowerCase()
|
|
|
|
|
);
|
|
|
|
|
return countryMatch || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Restore previously selected region from storage */
|
|
|
|
|
private restoreFromStorage(): void {
|
2026-07-15 02:58:39 +04:00
|
|
|
const region = this.storage.getJSON<Region>(STORAGE_KEY);
|
|
|
|
|
if (region) {
|
|
|
|
|
this.regionSignal.set(region);
|
2026-02-28 17:18:24 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Fallback regions if backend /regions endpoint is unavailable */
|
|
|
|
|
private getFallbackRegions(): Region[] {
|
|
|
|
|
return [
|
|
|
|
|
{ id: 'moscow', city: 'Москва', country: 'Россия', countryCode: 'RU', timezone: 'Europe/Moscow' },
|
|
|
|
|
{ id: 'spb', city: 'Санкт-Петербург', country: 'Россия', countryCode: 'RU', timezone: 'Europe/Moscow' },
|
|
|
|
|
{ id: 'yerevan', city: 'Ереван', country: 'Армения', countryCode: 'AM', timezone: 'Asia/Yerevan' },
|
|
|
|
|
{ id: 'minsk', city: 'Минск', country: 'Беларусь', countryCode: 'BY', timezone: 'Europe/Minsk' },
|
|
|
|
|
{ id: 'almaty', city: 'Алматы', country: 'Казахстан', countryCode: 'KZ', timezone: 'Asia/Almaty' },
|
|
|
|
|
{ id: 'tbilisi', city: 'Тбилиси', country: 'Грузия', countryCode: 'GE', timezone: 'Asia/Tbilisi' },
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|