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:
@@ -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()" [attr.aria-label]="'header.cart' | translate">
|
||||
@@ -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
|
||||
|
||||
@@ -18,14 +18,21 @@
|
||||
<div class="item-card">
|
||||
<a [routerLink]="['/item', product.itemID] | langRoute" class="item-link">
|
||||
<div class="item-image">
|
||||
<img [src]="getItemImage(product)" [alt]="product.name" loading="lazy" />
|
||||
<img [src]="getItemImage(product)" [alt]="itemName(product)" loading="lazy" />
|
||||
@if (product.discount > 0) {
|
||||
<span class="discount-badge">-{{ product.discount }}%</span>
|
||||
}
|
||||
@if (product.badges && product.badges.length > 0) {
|
||||
<div class="item-badges-overlay">
|
||||
@for (badge of product.badges; track badge) {
|
||||
<span class="item-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="item-details">
|
||||
<h3 class="item-name">{{ product.name }}</h3>
|
||||
<h3 class="item-name">{{ itemName(product) }}</h3>
|
||||
|
||||
@if (product.rating) {
|
||||
<div class="item-rating">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, signal, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Component, OnInit, signal, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { CarouselModule } from 'primeng/carousel';
|
||||
@@ -7,7 +7,8 @@ import { TagModule } from 'primeng/tag';
|
||||
import { ApiService, CartService } from '../../services';
|
||||
import { Item } from '../../models';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { getDiscountedPrice, getMainImage } from '../../utils/item.utils';
|
||||
import { getDiscountedPrice, getMainImage, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
|
||||
@@ -98,6 +99,10 @@ export class ItemsCarouselComponent implements OnInit {
|
||||
|
||||
readonly getItemImage = getMainImage;
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
|
||||
private langService = inject(LanguageService);
|
||||
itemName(product: Item): string { return getTranslatedField(product, 'name', this.langService.currentLanguage()); }
|
||||
|
||||
addToCart(event: Event, item: Item): void {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -24,4 +24,25 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="currency-button" (click)="toggleCurrency()">
|
||||
<span class="currency-symbol">{{ languageService.getCurrentCurrency()?.symbol }}</span>
|
||||
<span class="currency-code">{{ languageService.currentCurrency() }}</span>
|
||||
<svg class="dropdown-arrow" [class.rotated]="currencyOpen" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="currency-dropdown" [class.open]="currencyOpen">
|
||||
@for (cur of languageService.currencies; track cur.code) {
|
||||
<button
|
||||
class="currency-option"
|
||||
[class.active]="languageService.currentCurrency() === cur.code"
|
||||
(click)="selectCurrency(cur)">
|
||||
<span class="cur-symbol">{{ cur.symbol }}</span>
|
||||
<span class="cur-name">{{ cur.name }}</span>
|
||||
<span class="cur-code">{{ cur.code }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -301,3 +301,162 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Currency selector ──
|
||||
.language-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.currency-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.currency-symbol {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.currency-code {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
transition: transform 0.3s ease;
|
||||
opacity: 0.7;
|
||||
&.rotated { transform: rotate(180deg); }
|
||||
}
|
||||
}
|
||||
|
||||
.currency-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 170px;
|
||||
background: var(--card-bg, #1a1a1a);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.currency-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cur-symbol {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cur-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cur-code {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
// Light / Novo / Dexar theme adjustments for currency
|
||||
:host-context(.novo-header),
|
||||
:host-context(.header) {
|
||||
.currency-button {
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
color: #333333;
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.light-theme) {
|
||||
.currency-dropdown {
|
||||
background: #ffffff;
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.currency-option {
|
||||
color: #333333;
|
||||
&:hover { background: rgba(0, 0, 0, 0.05); }
|
||||
&.active { background: rgba(0, 0, 0, 0.1); }
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.dexar-header),
|
||||
:host-context(.dexar-mobile-menu) {
|
||||
.currency-button {
|
||||
padding: 4px 8px;
|
||||
gap: 3px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid #677b78;
|
||||
border-radius: 8px;
|
||||
color: #1e3c38;
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.currency-symbol { font-size: 14px; color: #1e3c38; }
|
||||
.currency-code { font-size: 13px; color: #1e3c38; }
|
||||
.dropdown-arrow path { stroke: #1e3c38; }
|
||||
}
|
||||
.currency-dropdown {
|
||||
background: #ffffff;
|
||||
border-color: #d3dad9;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.currency-option {
|
||||
color: #1e3c38;
|
||||
&:hover { background: rgba(161, 180, 181, 0.2); }
|
||||
&.active { background: rgba(73, 118, 113, 0.1); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, HostListener, ElementRef, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { LanguageService, Language } from '../../services/language.service';
|
||||
import { LanguageService, Language, Currency } from '../../services/language.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-language-selector',
|
||||
@@ -10,6 +10,7 @@ import { LanguageService, Language } from '../../services/language.service';
|
||||
})
|
||||
export class LanguageSelectorComponent {
|
||||
dropdownOpen = false;
|
||||
currencyOpen = false;
|
||||
|
||||
constructor(
|
||||
public languageService: LanguageService,
|
||||
@@ -18,6 +19,12 @@ export class LanguageSelectorComponent {
|
||||
|
||||
toggleDropdown(): void {
|
||||
this.dropdownOpen = !this.dropdownOpen;
|
||||
this.currencyOpen = false;
|
||||
}
|
||||
|
||||
toggleCurrency(): void {
|
||||
this.currencyOpen = !this.currencyOpen;
|
||||
this.dropdownOpen = false;
|
||||
}
|
||||
|
||||
selectLanguage(lang: Language): void {
|
||||
@@ -27,8 +34,14 @@ export class LanguageSelectorComponent {
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrency(currency: Currency): void {
|
||||
this.languageService.setCurrency(currency.code);
|
||||
this.currencyOpen = false;
|
||||
}
|
||||
|
||||
closeDropdown(): void {
|
||||
this.dropdownOpen = false;
|
||||
this.currencyOpen = false;
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent): void {
|
||||
@@ -44,6 +57,7 @@ export class LanguageSelectorComponent {
|
||||
onClickOutside(event: Event): void {
|
||||
if (!this.elementRef.nativeElement.contains(event.target)) {
|
||||
this.dropdownOpen = false;
|
||||
this.currencyOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user