diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts
index ca3c36e..929a437 100644
--- a/src/app/components/header/header.component.ts
+++ b/src/app/components/header/header.component.ts
@@ -4,12 +4,13 @@ import { CartService, LanguageService } from '../../services';
import { environment } from '../../../environments/environment';
import { LogoComponent } from '../logo/logo.component';
import { LanguageSelectorComponent } from '../language-selector/language-selector.component';
+import { RegionSelectorComponent } from '../region-selector/region-selector.component';
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
@Component({
selector: 'app-header',
- imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, LangRoutePipe, TranslatePipe],
+ imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, RegionSelectorComponent, LangRoutePipe, TranslatePipe],
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
diff --git a/src/app/components/region-selector/region-selector.component.html b/src/app/components/region-selector/region-selector.component.html
new file mode 100644
index 0000000..38bef6f
--- /dev/null
+++ b/src/app/components/region-selector/region-selector.component.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+ @if (detecting()) {
+ ...
+ } @else if (region()) {
+ {{ region()!.city }}
+ } @else {
+ {{ 'location.allRegions' | translate }}
+ }
+
+
+
+
+
+
+ @if (dropdownOpen()) {
+
+
+
+
+
+
+
+
+
+ {{ 'location.allRegions' | translate }}
+
+
+ @for (r of regions(); track r.id) {
+
+ {{ r.city }}
+ {{ r.country }}
+
+ }
+
+
+ }
+
diff --git a/src/app/components/region-selector/region-selector.component.scss b/src/app/components/region-selector/region-selector.component.scss
new file mode 100644
index 0000000..37ddbd7
--- /dev/null
+++ b/src/app/components/region-selector/region-selector.component.scss
@@ -0,0 +1,180 @@
+.region-selector {
+ position: relative;
+}
+
+.region-trigger {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 8px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--text-primary, #333);
+ transition: all 0.2s ease;
+ white-space: nowrap;
+
+ &:hover, &.active {
+ border-color: var(--accent-color, #497671);
+ background: var(--bg-hover, rgba(73, 118, 113, 0.05));
+ }
+
+ .pin-icon {
+ flex-shrink: 0;
+ color: var(--accent-color, #497671);
+ }
+
+ .region-name {
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ .detecting {
+ animation: pulse 1s ease infinite;
+ }
+ }
+
+ .chevron {
+ flex-shrink: 0;
+ transition: transform 0.2s ease;
+
+ &.rotated {
+ transform: rotate(180deg);
+ }
+ }
+}
+
+.region-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ min-width: 220px;
+ background: var(--bg-card, #fff);
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 12px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ z-index: 1000;
+ overflow: hidden;
+ animation: slideDown 0.15s ease;
+}
+
+.dropdown-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary, #666);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.detect-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 6px;
+ background: var(--bg-hover, rgba(73, 118, 113, 0.08));
+ color: var(--accent-color, #497671);
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: var(--accent-color, #497671);
+ color: #fff;
+ }
+}
+
+.region-list {
+ max-height: 280px;
+ overflow-y: auto;
+ padding: 4px;
+}
+
+.region-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 10px 12px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 14px;
+ color: var(--text-primary, #333);
+ text-align: left;
+ transition: background 0.15s ease;
+
+ &:hover {
+ background: var(--bg-hover, rgba(73, 118, 113, 0.06));
+ }
+
+ &.selected {
+ background: var(--accent-color, #497671);
+ color: #fff;
+
+ .region-country {
+ color: rgba(255, 255, 255, 0.7);
+ }
+ }
+
+ .region-city {
+ flex: 1;
+ }
+
+ .region-country {
+ font-size: 12px;
+ color: var(--text-secondary, #999);
+ }
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+// Mobile adjustments
+@media (max-width: 768px) {
+ .region-trigger {
+ padding: 5px 8px;
+ font-size: 12px;
+
+ .region-name {
+ max-width: 80px;
+ }
+ }
+
+ .region-dropdown {
+ position: fixed;
+ top: auto;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ min-width: 100%;
+ border-radius: 16px 16px 0 0;
+ max-height: 60vh;
+
+ .region-list {
+ max-height: 50vh;
+ }
+ }
+}
diff --git a/src/app/components/region-selector/region-selector.component.ts b/src/app/components/region-selector/region-selector.component.ts
new file mode 100644
index 0000000..622e0ed
--- /dev/null
+++ b/src/app/components/region-selector/region-selector.component.ts
@@ -0,0 +1,47 @@
+import { Component, ChangeDetectionStrategy, inject, signal, HostListener } from '@angular/core';
+import { LocationService } from '../../services/location.service';
+import { Region } from '../../models/location.model';
+import { TranslatePipe } from '../../i18n/translate.pipe';
+
+@Component({
+ selector: 'app-region-selector',
+ imports: [TranslatePipe],
+ templateUrl: './region-selector.component.html',
+ styleUrls: ['./region-selector.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class RegionSelectorComponent {
+ private locationService = inject(LocationService);
+
+ region = this.locationService.region;
+ regions = this.locationService.regions;
+ detecting = this.locationService.detecting;
+
+ dropdownOpen = signal(false);
+
+ toggleDropdown(): void {
+ this.dropdownOpen.update(v => !v);
+ }
+
+ selectRegion(region: Region): void {
+ this.locationService.setRegion(region);
+ this.dropdownOpen.set(false);
+ }
+
+ selectGlobal(): void {
+ this.locationService.clearRegion();
+ this.dropdownOpen.set(false);
+ }
+
+ detectLocation(): void {
+ this.locationService.detectLocation();
+ }
+
+ @HostListener('document:click', ['$event'])
+ onDocumentClick(event: MouseEvent): void {
+ const target = event.target as HTMLElement;
+ if (!target.closest('app-region-selector')) {
+ this.dropdownOpen.set(false);
+ }
+ }
+}
diff --git a/src/app/components/telegram-login/telegram-login.component.html b/src/app/components/telegram-login/telegram-login.component.html
new file mode 100644
index 0000000..42f3207
--- /dev/null
+++ b/src/app/components/telegram-login/telegram-login.component.html
@@ -0,0 +1,47 @@
+@if (showDialog()) {
+
+
+
+
+
+
+
+
+
+
+
{{ 'auth.loginRequired' | translate }}
+
{{ 'auth.loginDescription' | translate }}
+
+ @if (status() === 'checking') {
+
+
+
{{ 'auth.checking' | translate }}
+
+ } @else {
+
+
+
+
+ {{ 'auth.loginWithTelegram' | translate }}
+
+
+
+
{{ 'auth.orScanQr' | translate }}
+
+
+
+
+
+
{{ 'auth.loginNote' | translate }}
+ }
+
+
+}
diff --git a/src/app/components/telegram-login/telegram-login.component.scss b/src/app/components/telegram-login/telegram-login.component.scss
new file mode 100644
index 0000000..2b92f22
--- /dev/null
+++ b/src/app/components/telegram-login/telegram-login.component.scss
@@ -0,0 +1,184 @@
+.login-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ animation: fadeIn 0.2s ease;
+ padding: 16px;
+}
+
+.login-dialog {
+ position: relative;
+ background: var(--bg-card, #fff);
+ border-radius: 20px;
+ padding: 32px 28px;
+ max-width: 400px;
+ width: 100%;
+ text-align: center;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
+ animation: scaleIn 0.25s ease;
+}
+
+.close-btn {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 50%;
+ background: var(--bg-hover, #f0f0f0);
+ color: var(--text-secondary, #666);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: #e0e0e0;
+ color: #333;
+ }
+}
+
+.login-icon {
+ margin: 0 auto 16px;
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ background: var(--accent-light, rgba(73, 118, 113, 0.1));
+ color: var(--accent-color, #497671);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+h2 {
+ margin: 0 0 8px;
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text-primary, #1a1a1a);
+}
+
+.login-desc {
+ margin: 0 0 24px;
+ font-size: 14px;
+ color: var(--text-secondary, #666);
+ line-height: 1.5;
+}
+
+.telegram-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ width: 100%;
+ padding: 14px 24px;
+ border: none;
+ border-radius: 12px;
+ background: #2AABEE;
+ color: #fff;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: #229ED9;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+
+ .tg-icon {
+ flex-shrink: 0;
+ }
+}
+
+.qr-section {
+ margin-top: 20px;
+
+ .qr-hint {
+ margin: 0 0 12px;
+ font-size: 13px;
+ color: var(--text-secondary, #999);
+ }
+
+ .qr-container {
+ display: inline-flex;
+ padding: 12px;
+ background: #fff;
+ border-radius: 12px;
+ border: 1px solid #e8e8e8;
+
+ img {
+ display: block;
+ border-radius: 4px;
+ }
+ }
+}
+
+.login-note {
+ margin: 16px 0 0;
+ font-size: 12px;
+ color: var(--text-secondary, #999);
+ line-height: 1.4;
+}
+
+.login-status {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 16px;
+ color: var(--text-secondary, #666);
+ font-size: 14px;
+
+ .spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid #e0e0e0;
+ border-top-color: var(--accent-color, #497671);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes scaleIn {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+@media (max-width: 480px) {
+ .login-dialog {
+ padding: 24px 20px;
+ border-radius: 16px;
+ }
+
+ .qr-section .qr-container img {
+ width: 140px;
+ height: 140px;
+ }
+}
diff --git a/src/app/components/telegram-login/telegram-login.component.ts b/src/app/components/telegram-login/telegram-login.component.ts
new file mode 100644
index 0000000..d726320
--- /dev/null
+++ b/src/app/components/telegram-login/telegram-login.component.ts
@@ -0,0 +1,66 @@
+import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
+import { AuthService } from '../../services/auth.service';
+import { TranslatePipe } from '../../i18n/translate.pipe';
+
+@Component({
+ selector: 'app-telegram-login',
+ imports: [TranslatePipe],
+ templateUrl: './telegram-login.component.html',
+ styleUrls: ['./telegram-login.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class TelegramLoginComponent implements OnInit, OnDestroy {
+ private authService = inject(AuthService);
+
+ showDialog = this.authService.showLoginDialog;
+ status = this.authService.status;
+ loginUrl = signal('');
+
+ private pollTimer?: ReturnType
;
+
+ ngOnInit(): void {
+ this.loginUrl.set(this.authService.getTelegramLoginUrl());
+ }
+
+ ngOnDestroy(): void {
+ this.stopPolling();
+ }
+
+ close(): void {
+ this.authService.hideLogin();
+ this.stopPolling();
+ }
+
+ /** Open Telegram login link and start polling for session */
+ openTelegramLogin(): void {
+ window.open(this.loginUrl(), '_blank');
+ this.startPolling();
+ }
+
+ /** Start polling the backend to detect when user completes Telegram auth */
+ private startPolling(): void {
+ this.stopPolling();
+ // Check every 3 seconds for up to 5 minutes
+ let checks = 0;
+ this.pollTimer = setInterval(() => {
+ checks++;
+ if (checks > 100) { // 100 * 3s = 5 min
+ this.stopPolling();
+ return;
+ }
+ this.authService.checkSession();
+ // If authenticated, stop polling and close dialog
+ if (this.authService.isAuthenticated()) {
+ this.stopPolling();
+ this.authService.hideLogin();
+ }
+ }, 3000);
+ }
+
+ private stopPolling(): void {
+ if (this.pollTimer) {
+ clearInterval(this.pollTimer);
+ this.pollTimer = undefined;
+ }
+ }
+}
diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts
index d11f8dd..cc90315 100644
--- a/src/app/i18n/en.ts
+++ b/src/app/i18n/en.ts
@@ -186,4 +186,17 @@ export const en: Translations = {
retry: 'Try again',
loading: 'Loading...',
},
+ location: {
+ allRegions: 'All regions',
+ chooseRegion: 'Choose region',
+ detectAuto: 'Detect automatically',
+ },
+ auth: {
+ loginRequired: 'Login required',
+ loginDescription: 'Please log in via Telegram to proceed with your order',
+ checking: 'Checking...',
+ loginWithTelegram: 'Log in with Telegram',
+ orScanQr: 'Or scan the QR code',
+ loginNote: 'You will be redirected back after login',
+ },
};
diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts
index 26a6bbd..a1ff5bc 100644
--- a/src/app/i18n/hy.ts
+++ b/src/app/i18n/hy.ts
@@ -186,4 +186,18 @@ export const hy: Translations = {
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
loading: '╘▓╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
},
+
+ location: {
+ allRegions: 'Բոլոր տարածաշրջաններ',
+ chooseRegion: 'Ընտրեք տարածաշրջան',
+ detectAuto: 'Որոշել ինքնաշխատ',
+ },
+ auth: {
+ loginRequired: 'Մուտք պահանջվում է',
+ loginDescription: 'Պատվերի կատարման համար մուտք արեք Telegram-ի միջոցով',
+ checking: 'Ստուգում է...',
+ loginWithTelegram: 'Մուտք գործել Telegram-ով',
+ orScanQr: 'Կամ սկանավորեք QR կոդը',
+ loginNote: 'Մուտքից հետո դուք կվերադառնավեք',
+ },
};
diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts
index c70dc6d..c8679da 100644
--- a/src/app/i18n/ru.ts
+++ b/src/app/i18n/ru.ts
@@ -186,4 +186,17 @@ export const ru: Translations = {
retry: 'Попробовать снова',
loading: 'Загрузка...',
},
+ location: {
+ allRegions: 'Все регионы',
+ chooseRegion: 'Выберите регион',
+ detectAuto: 'Определить автоматически',
+ },
+ auth: {
+ loginRequired: 'Требуется авторизация',
+ loginDescription: 'Для оформления заказа войдите через Telegram',
+ checking: 'Проверка...',
+ loginWithTelegram: 'Войти через Telegram',
+ orScanQr: 'Или отсканируйте QR-код',
+ loginNote: 'После входа вы будете перенаправлены обратно',
+ },
};
diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts
index a3e0491..7ced44d 100644
--- a/src/app/i18n/translations.ts
+++ b/src/app/i18n/translations.ts
@@ -184,4 +184,17 @@ export interface Translations {
retry: string;
loading: string;
};
+ location: {
+ allRegions: string;
+ chooseRegion: string;
+ detectAuto: string;
+ };
+ auth: {
+ loginRequired: string;
+ loginDescription: string;
+ checking: string;
+ loginWithTelegram: string;
+ orScanQr: string;
+ loginNote: string;
+ };
}
diff --git a/src/app/models/auth.model.ts b/src/app/models/auth.model.ts
new file mode 100644
index 0000000..3ffde95
--- /dev/null
+++ b/src/app/models/auth.model.ts
@@ -0,0 +1,20 @@
+export interface AuthSession {
+ sessionId: string;
+ telegramUserId: number;
+ username: string | null;
+ displayName: string;
+ active: boolean;
+ expiresAt: string;
+}
+
+export interface TelegramAuthData {
+ id: number;
+ first_name: string;
+ last_name?: string;
+ username?: string;
+ photo_url?: string;
+ auth_date: number;
+ hash: string;
+}
+
+export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
diff --git a/src/app/models/index.ts b/src/app/models/index.ts
index d034c53..2c0918a 100644
--- a/src/app/models/index.ts
+++ b/src/app/models/index.ts
@@ -1,3 +1,4 @@
export * from './category.model';
export * from './item.model';
-
+export * from './location.model';
+export * from './auth.model';
diff --git a/src/app/models/location.model.ts b/src/app/models/location.model.ts
new file mode 100644
index 0000000..0b65b4d
--- /dev/null
+++ b/src/app/models/location.model.ts
@@ -0,0 +1,17 @@
+export interface Region {
+ id: string;
+ city: string;
+ country: string;
+ countryCode: string;
+ timezone?: string;
+}
+
+export interface GeoIpResponse {
+ city: string;
+ country: string;
+ countryCode: string;
+ region?: string;
+ timezone?: string;
+ lat?: number;
+ lon?: number;
+}
diff --git a/src/app/pages/cart/cart.component.ts b/src/app/pages/cart/cart.component.ts
index 58b4c90..e0e6065 100644
--- a/src/app/pages/cart/cart.component.ts
+++ b/src/app/pages/cart/cart.component.ts
@@ -2,7 +2,7 @@ import { Component, computed, ChangeDetectionStrategy, signal, OnDestroy, inject
import { DecimalPipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
-import { CartService, ApiService, LanguageService } from '../../services';
+import { CartService, ApiService, LanguageService, AuthService } from '../../services';
import { Item, CartItem } from '../../models';
import { interval, Subscription } from 'rxjs';
import { switchMap, take } from 'rxjs/operators';
@@ -28,6 +28,7 @@ export class CartComponent implements OnDestroy {
isnovo = environment.theme === 'novo';
private i18n = inject(TranslateService);
+ private authService = inject(AuthService);
// Swipe state
swipedItemId = signal(null);
@@ -135,6 +136,11 @@ export class CartComponent implements OnDestroy {
alert(this.i18n.t('cart.acceptTerms'));
return;
}
+ // Auth gate: require Telegram login before payment
+ if (!this.authService.isAuthenticated()) {
+ this.authService.requestLogin();
+ return;
+ }
this.openPaymentPopup();
}
diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts
new file mode 100644
index 0000000..2fbd5cd
--- /dev/null
+++ b/src/app/services/auth.service.ts
@@ -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(null);
+ private statusSignal = signal('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;
+
+ 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(`${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)['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;
+ }
+ }
+}
diff --git a/src/app/services/index.ts b/src/app/services/index.ts
index 4f32ba6..cae9e7a 100644
--- a/src/app/services/index.ts
+++ b/src/app/services/index.ts
@@ -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';
diff --git a/src/app/services/location.service.ts b/src/app/services/location.service.ts
new file mode 100644
index 0000000..59a8780
--- /dev/null
+++ b/src/app/services/location.service.ts
@@ -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(null);
+ private regionsSignal = signal([]);
+ 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(`${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('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' },
+ ];
+ }
+}
diff --git a/src/environments/environment.novo.production.ts b/src/environments/environment.novo.production.ts
index 2be3ca7..6e9b307 100644
--- a/src/environments/environment.novo.production.ts
+++ b/src/environments/environment.novo.production.ts
@@ -10,6 +10,7 @@ export const environment = {
supportEmail: 'info@novo.market',
domain: 'novo.market',
telegram: '@novomarket',
+ telegramBot: 'novomarket_bot',
phones: {
armenia: '+374 98 731231',
support: '+374 98 731231'
diff --git a/src/environments/environment.novo.ts b/src/environments/environment.novo.ts
index 8a8030d..45e4ad8 100644
--- a/src/environments/environment.novo.ts
+++ b/src/environments/environment.novo.ts
@@ -10,6 +10,7 @@ export const environment = {
supportEmail: 'info@novo.market',
domain: 'novo.market',
telegram: '@novomarket',
+ telegramBot: 'novomarket_bot',
phones: {
armenia: '+374 98 731231',
support: '+374 98 731231'
diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts
index 63732eb..959264d 100644
--- a/src/environments/environment.production.ts
+++ b/src/environments/environment.production.ts
@@ -10,6 +10,7 @@ export const environment = {
supportEmail: 'info@dexarmarket.ru',
domain: 'dexarmarket.ru',
telegram: '@dexarmarket',
+ telegramBot: 'dexarmarket_bot',
phones: {
russia: '+7 (926) 459-31-57',
armenia: '+374 94 86 18 16'
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 6a6d9b0..f256912 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -11,6 +11,7 @@ export const environment = {
supportEmail: 'info@dexarmarket.ru',
domain: 'dexarmarket.ru',
telegram: '@dexarmarket',
+ telegramBot: 'dexarmarket_bot',
phones: {
russia: '+7 (926) 459-31-57',
armenia: '+374 94 86 18 16'