Merge branch 'back-office-integration'
# Conflicts: # src/app/pages/cart/cart.component.ts # src/app/pages/category/category.component.html # src/app/pages/category/category.component.ts # src/app/pages/item-detail/item-detail.component.html # src/app/pages/item-detail/item-detail.component.ts # src/app/pages/legal/company-details/en/company-details-en.component.html # src/app/pages/legal/company-details/hy/company-details-hy.component.html # src/app/pages/legal/company-details/ru/company-details-ru.component.html # src/app/pages/legal/public-offer/en/public-offer-en.component.html # src/app/pages/legal/public-offer/ru/public-offer-ru.component.html # src/app/pages/search/search.component.ts # src/app/services/api.service.ts
This commit is contained in:
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, timer } from 'rxjs';
|
||||
import { map, retry } from 'rxjs/operators';
|
||||
import { Category, Item } from '../models';
|
||||
import { Category, Item, Subcategory } from '../models';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable({
|
||||
@@ -18,38 +18,174 @@ export class ApiService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
private normalizeItem(item: Item): Item {
|
||||
return {
|
||||
...item,
|
||||
remainings: item.remainings || 'high'
|
||||
};
|
||||
/**
|
||||
* Normalize an item from the API response — supports both
|
||||
* legacy marketplace format and the new backOffice API format.
|
||||
*/
|
||||
private normalizeItem(raw: any): Item {
|
||||
const { partnerID, ...rest } = raw;
|
||||
const item: Item = { ...rest };
|
||||
|
||||
// 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 }));
|
||||
}
|
||||
// Normalize photo type: API sends type='video'|'photo', template checks .video
|
||||
if (item.photos) {
|
||||
item.photos = item.photos.map((p: any) => ({
|
||||
...p,
|
||||
video: p.video || (p.type === 'video' ? p.url : undefined),
|
||||
}));
|
||||
}
|
||||
item.imgs = raw.imgs || raw.photos?.map((p: any) => p.url) || [];
|
||||
|
||||
// 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 || '';
|
||||
}
|
||||
|
||||
// Map backend names[] → translations (multi-lang name support)
|
||||
if (raw.names && Array.isArray(raw.names)) {
|
||||
item.names = raw.names;
|
||||
if (!item.translations) item.translations = {};
|
||||
for (const entry of raw.names) {
|
||||
if (!item.translations[entry.language]) item.translations[entry.language] = {};
|
||||
item.translations[entry.language].name = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Map backend descriptions[] → translations (multi-lang descriptions)
|
||||
if (raw.descriptions && Array.isArray(raw.descriptions)) {
|
||||
item.descriptions = raw.descriptions;
|
||||
if (!item.translations) item.translations = {};
|
||||
for (const entry of raw.descriptions) {
|
||||
if (!item.translations[entry.language]) item.translations[entry.language] = {};
|
||||
item.translations[entry.language].simpleDescription = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve attributes from backend
|
||||
item.attributes = raw.attributes || [];
|
||||
|
||||
// Preserve colour & size
|
||||
item.colour = raw.colour || '';
|
||||
item.size = raw.size || '';
|
||||
|
||||
// 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
|
||||
item.discount = item.discount || 0;
|
||||
item.remainings = item.remainings || (raw.quantity != null
|
||||
? (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 || '';
|
||||
item.translations = item.translations || raw.translations || {};
|
||||
item.visible = raw.visible ?? true;
|
||||
item.priority = raw.priority ?? 0;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private normalizeItems(items: Item[] | null | undefined): Item[] {
|
||||
private normalizeItems(items: any[] | null | undefined): Item[] {
|
||||
if (!items || !Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
return items.map(item => this.normalizeItem(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
cat.parentID = raw.parentID ?? 0;
|
||||
cat.visible = raw.visible ?? true;
|
||||
cat.priority = raw.priority ?? 0;
|
||||
|
||||
if (raw.subcategories && Array.isArray(raw.subcategories)) {
|
||||
cat.subcategories = raw.subcategories;
|
||||
}
|
||||
|
||||
return cat;
|
||||
}
|
||||
|
||||
private normalizeCategories(cats: any[] | null | undefined): Category[] {
|
||||
if (!cats || !Array.isArray(cats)) return [];
|
||||
return cats.map(c => this.normalizeCategory(c));
|
||||
}
|
||||
|
||||
// ─── Core Marketplace Endpoints ───────────────────────────
|
||||
|
||||
ping(): Observable<{ message: string }> {
|
||||
return this.http.get<{ message: string }>(`${this.baseUrl}/ping`);
|
||||
}
|
||||
|
||||
getCategories(): Observable<Category[]> {
|
||||
return this.http.get<Category[]>(`${this.baseUrl}/category`).pipe(retry(this.retryConfig));
|
||||
return this.http.get<any[]>(`${this.baseUrl}/category`)
|
||||
.pipe(retry(this.retryConfig), map(cats => this.normalizeCategories(cats)));
|
||||
}
|
||||
|
||||
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
|
||||
const params = new HttpParams()
|
||||
.set('count', count.toString())
|
||||
.set('skip', skip.toString());
|
||||
return this.http.get<Item[]>(`${this.baseUrl}/category/${categoryID}`, { params })
|
||||
return this.http.get<any[]>(`${this.baseUrl}/category/${categoryID}`, { params })
|
||||
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
|
||||
}
|
||||
|
||||
getItem(itemID: number): Observable<Item> {
|
||||
return this.http.get<Item>(`${this.baseUrl}/item/${itemID}`)
|
||||
return this.http.get<any>(`${this.baseUrl}/item/${itemID}`)
|
||||
.pipe(retry(this.retryConfig), map(item => this.normalizeItem(item)));
|
||||
}
|
||||
|
||||
@@ -58,7 +194,7 @@ export class ApiService {
|
||||
.set('search', search)
|
||||
.set('count', count.toString())
|
||||
.set('skip', skip.toString());
|
||||
return this.http.get<{ items: Item[], total: number, count: number, skip: number }>(`${this.baseUrl}/searchitems`, { params })
|
||||
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
|
||||
.pipe(
|
||||
retry(this.retryConfig),
|
||||
map(response => ({
|
||||
@@ -81,7 +217,7 @@ export class ApiService {
|
||||
}
|
||||
|
||||
getCart(): Observable<Item[]> {
|
||||
return this.http.get<Item[]>(`${this.baseUrl}/cart`)
|
||||
return this.http.get<any[]>(`${this.baseUrl}/cart`)
|
||||
.pipe(map(items => this.normalizeItems(items)));
|
||||
}
|
||||
|
||||
@@ -167,7 +303,7 @@ export class ApiService {
|
||||
if (categoryID) {
|
||||
params = params.set('category', categoryID.toString());
|
||||
}
|
||||
return this.http.get<Item[]>(`${this.baseUrl}/randomitems`, { params })
|
||||
return this.http.get<any[]>(`${this.baseUrl}/randomitems`, { params })
|
||||
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
|
||||
}
|
||||
}
|
||||
|
||||
128
src/app/services/auth.service.ts
Normal file
128
src/app/services/auth.service.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, catchError, map, tap } from 'rxjs';
|
||||
import { AuthSession, AuthStatus } from '../models/auth.model';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private sessionSignal = signal<AuthSession | null>(null);
|
||||
private statusSignal = signal<AuthStatus>('unknown');
|
||||
private showLoginSignal = signal(false);
|
||||
|
||||
/** Current auth session */
|
||||
readonly session = this.sessionSignal.asReadonly();
|
||||
/** Current auth status */
|
||||
readonly status = this.statusSignal.asReadonly();
|
||||
/** Whether user is fully authenticated */
|
||||
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
||||
/** Whether to show login dialog */
|
||||
readonly showLoginDialog = this.showLoginSignal.asReadonly();
|
||||
/** Display name of authenticated user */
|
||||
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
||||
|
||||
private readonly apiUrl = environment.apiUrl;
|
||||
private sessionCheckTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
// On init, check existing session via cookie
|
||||
this.checkSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current session status with backend.
|
||||
* The backend reads the session cookie and returns the session info.
|
||||
*/
|
||||
checkSession(): void {
|
||||
this.statusSignal.set('checking');
|
||||
|
||||
this.http.get<AuthSession>(`${this.apiUrl}/auth/session`, {
|
||||
withCredentials: true
|
||||
}).pipe(
|
||||
catchError(() => {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.sessionSignal.set(null);
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(session => {
|
||||
if (session && session.active) {
|
||||
this.sessionSignal.set(session);
|
||||
this.statusSignal.set('authenticated');
|
||||
this.scheduleSessionRefresh(session.expiresAt);
|
||||
} else if (session && !session.active) {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set('expired');
|
||||
} else {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after user completes Telegram login.
|
||||
* The callback URL from Telegram will hit our backend which sets the cookie.
|
||||
* Then we re-check the session.
|
||||
*/
|
||||
onTelegramLoginComplete(): void {
|
||||
this.checkSession();
|
||||
this.hideLogin();
|
||||
}
|
||||
|
||||
/** Generate the Telegram login URL for bot-based auth */
|
||||
getTelegramLoginUrl(): string {
|
||||
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'dexarmarket_bot';
|
||||
const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`);
|
||||
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
|
||||
}
|
||||
|
||||
/** Get QR code data URL for Telegram login */
|
||||
getTelegramQrUrl(): string {
|
||||
return this.getTelegramLoginUrl();
|
||||
}
|
||||
|
||||
/** Show login dialog (called when user tries to pay without being logged in) */
|
||||
requestLogin(): void {
|
||||
this.showLoginSignal.set(true);
|
||||
}
|
||||
|
||||
/** Hide login dialog */
|
||||
hideLogin(): void {
|
||||
this.showLoginSignal.set(false);
|
||||
}
|
||||
|
||||
/** Logout — clears session on backend and locally */
|
||||
logout(): void {
|
||||
this.http.post(`${this.apiUrl}/auth/logout`, {}, {
|
||||
withCredentials: true
|
||||
}).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe(() => {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.clearSessionRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
/** Schedule a session re-check before it expires */
|
||||
private scheduleSessionRefresh(expiresAt: string): void {
|
||||
this.clearSessionRefresh();
|
||||
|
||||
const expiresMs = new Date(expiresAt).getTime();
|
||||
const nowMs = Date.now();
|
||||
// Re-check 60 seconds before expiry, minimum 30s from now
|
||||
const refreshIn = Math.max(expiresMs - nowMs - 60_000, 30_000);
|
||||
|
||||
this.sessionCheckTimer = setTimeout(() => {
|
||||
this.checkSession();
|
||||
}, refreshIn);
|
||||
}
|
||||
|
||||
private clearSessionRefresh(): void {
|
||||
if (this.sessionCheckTimer) {
|
||||
clearTimeout(this.sessionCheckTimer);
|
||||
this.sessionCheckTimer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export * from './cart.service';
|
||||
export * from './telegram.service';
|
||||
export * from './language.service';
|
||||
export * from './seo.service';
|
||||
export * from './location.service';
|
||||
export * from './auth.service';
|
||||
|
||||
@@ -9,11 +9,18 @@ export interface Language {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Currency {
|
||||
code: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LanguageService {
|
||||
private currentLanguageSignal = signal<string>('ru');
|
||||
private currentCurrencySignal = signal<string>('RUB');
|
||||
|
||||
languages: Language[] = [
|
||||
{ code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true },
|
||||
@@ -21,7 +28,15 @@ export class LanguageService {
|
||||
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: false }
|
||||
];
|
||||
|
||||
currencies: Currency[] = [
|
||||
{ code: 'RUB', symbol: '₽', name: 'Рубль' },
|
||||
{ code: 'USD', symbol: '$', name: 'Dollar' },
|
||||
{ code: 'EUR', symbol: '€', name: 'Euro' },
|
||||
{ code: 'AMD', symbol: '֏', name: 'Դրամ' },
|
||||
];
|
||||
|
||||
currentLanguage = this.currentLanguageSignal.asReadonly();
|
||||
currentCurrency = this.currentCurrencySignal.asReadonly();
|
||||
|
||||
constructor(private router: Router) {
|
||||
// Load saved language from localStorage
|
||||
@@ -29,6 +44,11 @@ export class LanguageService {
|
||||
if (savedLang && this.languages.find(l => l.code === savedLang && l.enabled)) {
|
||||
this.currentLanguageSignal.set(savedLang);
|
||||
}
|
||||
|
||||
const savedCurrency = localStorage.getItem('selectedCurrency');
|
||||
if (savedCurrency && this.currencies.find(c => c.code === savedCurrency)) {
|
||||
this.currentCurrencySignal.set(savedCurrency);
|
||||
}
|
||||
}
|
||||
|
||||
setLanguage(langCode: string): void {
|
||||
@@ -39,6 +59,18 @@ export class LanguageService {
|
||||
}
|
||||
}
|
||||
|
||||
setCurrency(code: string): void {
|
||||
const currency = this.currencies.find(c => c.code === code);
|
||||
if (currency) {
|
||||
this.currentCurrencySignal.set(code);
|
||||
localStorage.setItem('selectedCurrency', code);
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentCurrency(): Currency | undefined {
|
||||
return this.currencies.find(c => c.code === this.currentCurrencySignal());
|
||||
}
|
||||
|
||||
/** Change language and navigate to the same page with the new prefix */
|
||||
switchLanguage(langCode: string): void {
|
||||
const lang = this.languages.find(l => l.code === langCode);
|
||||
|
||||
135
src/app/services/location.service.ts
Normal file
135
src/app/services/location.service.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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';
|
||||
|
||||
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 ?? '');
|
||||
|
||||
private readonly apiUrl = environment.apiUrl;
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.loadRegions();
|
||||
this.restoreFromStorage();
|
||||
}
|
||||
|
||||
/** Fetch available regions from backend */
|
||||
loadRegions(): void {
|
||||
this.http.get<Region[]>(`${this.apiUrl}/regions`).subscribe({
|
||||
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);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(region));
|
||||
}
|
||||
|
||||
/** Clear region (go global) */
|
||||
clearRegion(): void {
|
||||
this.regionSignal.set(null);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const region: Region = JSON.parse(stored);
|
||||
this.regionSignal.set(region);
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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' },
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user