Merge branch 'auth-system' into back-office-integration
This commit is contained in:
@@ -19,4 +19,5 @@
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-footer></app-footer>
|
||||
<app-telegram-login />
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Title } from '@angular/platform-browser';
|
||||
import { HeaderComponent } from './components/header/header.component';
|
||||
import { FooterComponent } from './components/footer/footer.component';
|
||||
import { BackButtonComponent } from './components/back-button/back-button.component';
|
||||
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
|
||||
import { ApiService } from './services';
|
||||
import { interval, concat } from 'rxjs';
|
||||
import { filter, first } from 'rxjs/operators';
|
||||
@@ -16,7 +17,7 @@ import { TranslateService } from './i18n/translate.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe],
|
||||
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TelegramLoginComponent, TranslatePipe],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
</nav>
|
||||
|
||||
<div class="novo-right">
|
||||
<app-region-selector />
|
||||
<app-language-selector />
|
||||
|
||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()">
|
||||
@@ -106,6 +107,11 @@
|
||||
}
|
||||
</a>
|
||||
|
||||
<!-- Region Selector (desktop only) -->
|
||||
<div class="dexar-region-selector dexar-lang-desktop">
|
||||
<app-region-selector />
|
||||
</div>
|
||||
|
||||
<!-- Language Selector (desktop only) -->
|
||||
<div class="dexar-lang-selector dexar-lang-desktop">
|
||||
<app-language-selector />
|
||||
@@ -171,6 +177,11 @@
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Region Selector in mobile menu -->
|
||||
<div class="dexar-mobile-lang">
|
||||
<app-region-selector />
|
||||
</div>
|
||||
|
||||
<!-- Language Selector in mobile menu -->
|
||||
<div class="dexar-mobile-lang">
|
||||
<app-language-selector />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<div class="region-selector">
|
||||
<button class="region-trigger" (click)="toggleDropdown()" [class.active]="dropdownOpen()">
|
||||
<svg class="pin-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
<span class="region-name">
|
||||
@if (detecting()) {
|
||||
<span class="detecting">...</span>
|
||||
} @else if (region()) {
|
||||
{{ region()!.city }}
|
||||
} @else {
|
||||
{{ 'location.allRegions' | translate }}
|
||||
}
|
||||
</span>
|
||||
<svg class="chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
[class.rotated]="dropdownOpen()">
|
||||
<path d="M6 9l6 6 6-6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (dropdownOpen()) {
|
||||
<div class="region-dropdown">
|
||||
<div class="dropdown-header">
|
||||
<span>{{ 'location.chooseRegion' | translate }}</span>
|
||||
@if (!detecting()) {
|
||||
<button class="detect-btn" (click)="detectLocation()" title="{{ 'location.detectAuto' | translate }}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4"></path>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="region-list">
|
||||
<button class="region-option" [class.selected]="!region()" (click)="selectGlobal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
</svg>
|
||||
<span>{{ 'location.allRegions' | translate }}</span>
|
||||
</button>
|
||||
|
||||
@for (r of regions(); track r.id) {
|
||||
<button class="region-option" [class.selected]="region()?.id === r.id" (click)="selectRegion(r)">
|
||||
<span class="region-city">{{ r.city }}</span>
|
||||
<span class="region-country">{{ r.country }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@if (showDialog()) {
|
||||
<div class="login-overlay" (click)="close()">
|
||||
<div class="login-dialog" (click)="$event.stopPropagation()">
|
||||
<button class="close-btn" (click)="close()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="login-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2>{{ 'auth.loginRequired' | translate }}</h2>
|
||||
<p class="login-desc">{{ 'auth.loginDescription' | translate }}</p>
|
||||
|
||||
@if (status() === 'checking') {
|
||||
<div class="login-status checking">
|
||||
<div class="spinner"></div>
|
||||
<span>{{ 'auth.checking' | translate }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<button class="telegram-btn" (click)="openTelegramLogin()">
|
||||
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
{{ 'auth.loginWithTelegram' | translate }}
|
||||
</button>
|
||||
|
||||
<div class="qr-section">
|
||||
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
|
||||
<div class="qr-container">
|
||||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + loginUrl()"
|
||||
alt="QR Code"
|
||||
width="180"
|
||||
height="180"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
184
src/app/components/telegram-login/telegram-login.component.scss
Normal file
184
src/app/components/telegram-login/telegram-login.component.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setInterval>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -186,4 +186,18 @@ export const hy: Translations = {
|
||||
retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢',
|
||||
loading: '╘▓╒е╒╝╒╢╒╛╒╕╓В╒┤ ╒з...',
|
||||
},
|
||||
|
||||
location: {
|
||||
allRegions: 'Բոլոր տարածաշրջաններ',
|
||||
chooseRegion: 'Ընտրեք տարածաշրջան',
|
||||
detectAuto: 'Որոշել ինքնաշխատ',
|
||||
},
|
||||
auth: {
|
||||
loginRequired: 'Մուտք պահանջվում է',
|
||||
loginDescription: 'Պատվերի կատարման համար մուտք արեք Telegram-ի միջոցով',
|
||||
checking: 'Ստուգում է...',
|
||||
loginWithTelegram: 'Մուտք գործել Telegram-ով',
|
||||
orScanQr: 'Կամ սկանավորեք QR կոդը',
|
||||
loginNote: 'Մուտքից հետո դուք կվերադառնավեք',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -186,4 +186,17 @@ export const ru: Translations = {
|
||||
retry: 'Попробовать снова',
|
||||
loading: 'Загрузка...',
|
||||
},
|
||||
location: {
|
||||
allRegions: 'Все регионы',
|
||||
chooseRegion: 'Выберите регион',
|
||||
detectAuto: 'Определить автоматически',
|
||||
},
|
||||
auth: {
|
||||
loginRequired: 'Требуется авторизация',
|
||||
loginDescription: 'Для оформления заказа войдите через Telegram',
|
||||
checking: 'Проверка...',
|
||||
loginWithTelegram: 'Войти через Telegram',
|
||||
orScanQr: 'Или отсканируйте QR-код',
|
||||
loginNote: 'После входа вы будете перенаправлены обратно',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
20
src/app/models/auth.model.ts
Normal file
20
src/app/models/auth.model.ts
Normal file
@@ -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';
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './category.model';
|
||||
export * from './item.model';
|
||||
|
||||
export * from './location.model';
|
||||
export * from './auth.model';
|
||||
|
||||
17
src/app/models/location.model.ts
Normal file
17
src/app/models/location.model.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<number | null>(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();
|
||||
}
|
||||
|
||||
|
||||
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';
|
||||
|
||||
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' },
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user