changes and optimisations

This commit is contained in:
sdarbinyan
2026-03-06 17:45:34 +04:00
parent c112aded47
commit c3e4e695eb
9 changed files with 129 additions and 69 deletions

View File

@@ -167,7 +167,7 @@ export class CartComponent implements OnDestroy {
const paymentData = {
amount: this.totalPrice(),
currency: 'RUB',
currency: this.items()[0]?.currency || 'RUB',
siteuserID: userId,
siteorderID: orderId,
redirectUrl: '',
@@ -193,6 +193,7 @@ export class CartComponent implements OnDestroy {
error: (err) => {
console.error('Error creating payment:', err);
this.paymentStatus.set('timeout');
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => {
this.closePaymentPopup();
}, 4000);
@@ -201,6 +202,7 @@ export class CartComponent implements OnDestroy {
}
startPolling(): void {
this.stopPolling();
this.pollingSubscription = interval(5000) // every 5 seconds
.pipe(
take(this.maxChecks), // maximum 36 checks (3 minutes)
@@ -225,6 +227,7 @@ export class CartComponent implements OnDestroy {
if (this.paymentStatus() === 'waiting') {
this.paymentStatus.set('timeout');
// Close popup after showing timeout message
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => {
this.closePaymentPopup();
}, 3000);
@@ -233,6 +236,7 @@ export class CartComponent implements OnDestroy {
error: (err) => {
console.error('Error checking payment status:', err);
// Continue checking even on error until time runs out
if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => {
this.closePaymentPopup();
}, 3000);

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { Component, OnInit, OnDestroy, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { ApiService, LanguageService } from '../../services';
import { Category } from '../../models';
@@ -14,7 +14,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
styleUrls: ['./home.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HomeComponent implements OnInit {
export class HomeComponent implements OnInit, OnDestroy {
brandName = environment.brandFullName;
isnovo = environment.theme === 'novo';
categories = signal<Category[]>([]);
@@ -56,6 +56,14 @@ export class HomeComponent implements OnInit {
this.loadCategories();
}
ngOnDestroy(): void {
this.pendingImages.forEach(img => {
img.onload = null;
img.onerror = null;
});
this.pendingImages.clear();
}
loadCategories(): void {
this.loading.set(true);
this.apiService.getCategories().subscribe({
@@ -84,13 +92,17 @@ export class HomeComponent implements OnInit {
return this.wideCategories().has(categoryID);
}
private pendingImages = new Set<HTMLImageElement>();
private detectWideImages(categories: Category[]): void {
const topLevel = categories.filter(c => c.parentID === 0);
topLevel.forEach(cat => {
if (!cat.wideBanner) return;
const img = new Image();
this.pendingImages.add(img);
img.onload = () => {
this.pendingImages.delete(img);
const ratio = img.naturalWidth / img.naturalHeight;
if (ratio > 2) {
this.wideCategories.update(set => {
@@ -100,6 +112,7 @@ export class HomeComponent implements OnInit {
});
}
};
img.onerror = () => this.pendingImages.delete(img);
img.src = cat.wideBanner;
});
}

View File

@@ -15,21 +15,22 @@
</div>
}
@if (item() && !loading()) {
@if (item(); as item) {
@if (!loading()) {
<div class="novo-item-content">
<div class="novo-gallery">
@if (item()?.photos && item()!.photos!.length > 0) {
@if (item.photos && item.photos.length > 0) {
<div class="novo-main-photo">
@if (item()!.photos![selectedPhotoIndex()]?.video) {
<video [src]="item()!.photos![selectedPhotoIndex()].url" controls></video>
@if (item.photos[selectedPhotoIndex()]?.video) {
<video [src]="item.photos[selectedPhotoIndex()].url" controls></video>
} @else {
<img [src]="item()!.photos![selectedPhotoIndex()].url" [alt]="item()!.name" />
<img [src]="item.photos[selectedPhotoIndex()].url" [alt]="item.name" />
}
</div>
@if (item()!.photos!.length > 1) {
@if (item.photos.length > 1) {
<div class="novo-thumbnails">
@for (photo of item()!.photos!; track $index) {
@for (photo of item.photos; track $index) {
<div
class="novo-thumb"
[class.active]="selectedPhotoIndex() === $index"
@@ -55,31 +56,31 @@
</div>
<div class="novo-info">
<h1 class="novo-title">{{ item()!.name }}</h1>
<h1 class="novo-title">{{ item.name }}</h1>
<div class="novo-rating">
<span class="stars">{{ getRatingStars(item()!.rating) }}</span>
<span class="value">{{ item()!.rating }}</span>
<span class="reviews">({{ item()!.callbacks?.length || 0 }})</span>
<span class="stars">{{ getRatingStars(item.rating) }}</span>
<span class="value">{{ item.rating }}</span>
<span class="reviews">({{ item.callbacks?.length || 0 }})</span>
</div>
<div class="novo-price-block">
@if (item()!.discount > 0) {
@if (item.discount > 0) {
<div class="price-row">
<span class="old-price">{{ item()!.price }} {{ item()!.currency }}</span>
<span class="discount-badge">-{{ item()!.discount }}%</span>
<span class="old-price">{{ item.price }} {{ item.currency }}</span>
<span class="discount-badge">-{{ item.discount }}%</span>
</div>
<div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ item()!.currency }}</div>
<div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ item.currency }}</div>
} @else {
<div class="current-price">{{ item()!.price }} {{ item()!.currency }}</div>
<div class="current-price">{{ item.price }} {{ item.currency }}</div>
}
</div>
<div class="novo-stock">
<span class="stock-label">{{ 'itemDetail.stock' | translate }}</span>
<div class="stock-indicator" [class.high]="item()!.remainings === 'high'" [class.medium]="item()!.remainings === 'medium'" [class.low]="item()!.remainings === 'low'">
<div class="stock-indicator" [class.high]="item.remainings === 'high'" [class.medium]="item.remainings === 'medium'" [class.low]="item.remainings === 'low'">
<span class="dot"></span>
{{ item()!.remainings === 'high' ? ('itemDetail.inStock' | translate) : item()!.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lowStock' | translate) }}
{{ item.remainings === 'high' ? ('itemDetail.inStock' | translate) : item.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lowStock' | translate) }}
</div>
</div>
@@ -94,13 +95,13 @@
<div class="novo-description">
<h3>{{ 'itemDetail.description' | translate }}</h3>
<div [innerHTML]="getSafeHtml(item()!.description)"></div>
<div [innerHTML]="getSafeHtml(item.description)"></div>
</div>
</div>
</div>
<div class="novo-reviews">
<h2>{{ 'itemDetail.reviews' | translate }} ({{ item()!.callbacks?.length || 0 }})</h2>
<h2>{{ 'itemDetail.reviews' | translate }} ({{ item.callbacks?.length || 0 }})</h2>
<!-- novo Review Form -->
<div class="novo-review-form">
@@ -169,8 +170,8 @@
</div>
<div class="novo-reviews-list">
@if (item()!.callbacks && item()!.callbacks!.length > 0) {
@for (review of item()!.callbacks!; track review.userID) {
@if (item.callbacks && item.callbacks.length > 0) {
@for (review of item.callbacks; track $index) {
<div class="novo-review-card">
<div class="review-header">
<div class="reviewer-info">
@@ -189,6 +190,7 @@
}
</div>
</div>
}
}
</div>
} @else {
@@ -208,13 +210,14 @@
</div>
}
@if (item() && !loading()) {
@if (item(); as item) {
@if (!loading()) {
<div class="dx-item-content">
<!-- Gallery: thumbnails left + main photo -->
<div class="dx-gallery">
@if (item()?.photos && item()!.photos!.length > 0) {
@if (item.photos && item.photos.length > 0) {
<div class="dx-thumbnails">
@for (photo of item()!.photos!; track $index) {
@for (photo of item.photos; track $index) {
<div
class="dx-thumb"
[class.active]="selectedPhotoIndex() === $index"
@@ -228,11 +231,11 @@
</div>
}
<div class="dx-main-photo">
@if (item()?.photos && item()!.photos!.length > 0) {
@if (item()!.photos![selectedPhotoIndex()]?.video) {
<video [src]="item()!.photos![selectedPhotoIndex()].url" controls></video>
@if (item.photos && item.photos.length > 0) {
@if (item.photos[selectedPhotoIndex()]?.video) {
<video [src]="item.photos[selectedPhotoIndex()].url" controls></video>
} @else {
<img [src]="item()!.photos![selectedPhotoIndex()].url" [alt]="item()!.name" fetchpriority="high" decoding="async" />
<img [src]="item.photos[selectedPhotoIndex()].url" [alt]="item.name" fetchpriority="high" decoding="async" />
}
} @else {
<div class="dx-no-image">
@@ -249,40 +252,40 @@
<!-- Item Info -->
<div class="dx-info">
<h1 class="dx-title">{{ item()!.name }}</h1>
<h1 class="dx-title">{{ item.name }}</h1>
<div class="dx-rating">
<div class="dx-stars">
@for (star of [1, 2, 3, 4, 5]; track star) {
<svg width="18" height="18" viewBox="0 0 24 24" [attr.fill]="star <= item()!.rating ? '#497671' : 'none'" [attr.stroke]="star <= item()!.rating ? '#497671' : '#a1b4b5'" stroke-width="2">
<svg width="18" height="18" viewBox="0 0 24 24" [attr.fill]="star <= item.rating ? '#497671' : 'none'" [attr.stroke]="star <= item.rating ? '#497671' : '#a1b4b5'" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
}
</div>
<span class="dx-rating-value">{{ item()!.rating }}</span>
<span class="dx-rating-count">({{ item()!.callbacks?.length || 0 }} {{ 'itemDetail.reviewsCount' | translate }})</span>
<span class="dx-rating-value">{{ item.rating }}</span>
<span class="dx-rating-count">({{ item.callbacks?.length || 0 }} {{ 'itemDetail.reviewsCount' | translate }})</span>
</div>
<div class="dx-price-block">
@if (item()!.discount > 0) {
@if (item.discount > 0) {
<div class="dx-price-row">
<span class="dx-old-price">{{ item()!.price }} {{ item()!.currency }}</span>
<span class="dx-discount-tag">-{{ item()!.discount }}%</span>
<span class="dx-old-price">{{ item.price }} {{ item.currency }}</span>
<span class="dx-discount-tag">-{{ item.discount }}%</span>
</div>
}
<div class="dx-current-price">
{{ item()!.discount > 0 ? (getDiscountedPrice() | number:'1.2-2') : item()!.price }} {{ item()!.currency }}
{{ item.discount > 0 ? (getDiscountedPrice() | number:'1.2-2') : item.price }} {{ item.currency }}
</div>
</div>
<div class="dx-stock">
<span class="dx-stock-label">{{ 'itemDetail.stock' | translate }}</span>
<span class="dx-stock-status"
[class.high]="item()!.remainings === 'high'"
[class.medium]="item()!.remainings === 'medium'"
[class.low]="item()!.remainings === 'low'">
[class.high]="item.remainings === 'high'"
[class.medium]="item.remainings === 'medium'"
[class.low]="item.remainings === 'low'">
<span class="dx-stock-dot"></span>
{{ item()!.remainings === 'high' ? ('itemDetail.inStock' | translate) : item()!.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lastItems' | translate) }}
{{ item.remainings === 'high' ? ('itemDetail.inStock' | translate) : item.remainings === 'medium' ? ('itemDetail.mediumStock' | translate) : ('itemDetail.lastItems' | translate) }}
</span>
</div>
@@ -297,14 +300,14 @@
<div class="dx-description">
<h2>{{ 'itemDetail.description' | translate }}</h2>
<div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div>
<div class="dx-description-text" [innerHTML]="getSafeHtml(item.description)"></div>
</div>
</div>
</div>
<!-- Reviews Section -->
<div class="dx-reviews-section">
<h2>{{ 'itemDetail.reviews' | translate }} ({{ item()!.callbacks?.length || 0 }})</h2>
<h2>{{ 'itemDetail.reviews' | translate }} ({{ item.callbacks?.length || 0 }})</h2>
<div class="dx-review-form">
<h3>{{ 'itemDetail.leaveReview' | translate }}</h3>
@@ -365,8 +368,8 @@
</div>
<div class="dx-reviews-list">
@if (item()?.callbacks && item()!.callbacks!.length > 0) {
@for (callback of item()!.callbacks; track $index) {
@if (item.callbacks && item.callbacks.length > 0) {
@for (callback of item.callbacks; track $index) {
<div class="dx-review-card">
<div class="dx-review-header">
<div class="dx-reviewer">
@@ -397,11 +400,11 @@
</div>
<!-- Q&A Section -->
@if (item()!.questions && item()!.questions!.length > 0) {
@if (item.questions && item.questions.length > 0) {
<div class="dx-qa-section">
<h2>{{ 'itemDetail.qna' | translate }} ({{ item()!.questions!.length }})</h2>
<h2>{{ 'itemDetail.qna' | translate }} ({{ item.questions.length }})</h2>
<div class="dx-qa-list">
@for (question of item()!.questions!; track $index) {
@for (question of item.questions; track $index) {
<div class="dx-qa-card">
<div class="dx-question">
<span class="dx-qa-label q">В</span>
@@ -426,6 +429,7 @@
</div>
</div>
}
}
}
</div>
}

View File

@@ -1,4 +1,6 @@
// ========== DEXAR ITEM DETAIL - Redesigned 2026 ==========
@use 'sass:color';
// ========== DEXAR ITEM DETAIL - Redesigned 2026 ==========
$dx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
$dx-dark: #1e3c38;
$dx-primary: #497671;
@@ -50,7 +52,7 @@ $dx-card-bg: #f5f3f9;
transition: all 0.2s;
&:hover {
background: darken($dx-primary, 8%);
background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-1px);
}
}
@@ -281,7 +283,7 @@ $dx-card-bg: #f5f3f9;
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
&:hover {
background: darken($dx-primary, 8%);
background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(73, 118, 113, 0.3);
}
@@ -434,7 +436,7 @@ $dx-card-bg: #f5f3f9;
justify-content: center;
&:hover:not(:disabled) {
background: darken($dx-primary, 8%);
background: color.adjust($dx-primary, $lightness: -8%);
transform: translateY(-1px);
}

View File

@@ -2,9 +2,9 @@ import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject }
import { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService, TelegramService, SeoService } from '../../services';
import { ApiService, CartService, TelegramService, SeoService, LanguageService } from '../../services';
import { Item } from '../../models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { DomSanitizer } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
import { environment } from '../../../environments/environment';
import { SecurityContext } from '@angular/core';
@@ -42,6 +42,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
private seoService = inject(SeoService);
private i18n = inject(TranslateService);
private langService = inject(LanguageService);
constructor(
private route: ActivatedRoute,
@@ -100,7 +101,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
return getDiscountedPrice(currentItem);
}
getSafeHtml(html: string): SafeHtml {
getSafeHtml(html: string): string {
return this.sanitizer.sanitize(SecurityContext.HTML, html) || '';
}
@@ -123,7 +124,9 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
if (diffDays < 7) return `${diffDays} ${this.i18n.t('itemDetail.daysAgo')}`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} ${this.i18n.t('itemDetail.weeksAgo')}`;
return date.toLocaleDateString('ru-RU', {
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', hy: 'hy-AM' };
const locale = localeMap[this.langService.currentLanguage()] || 'ru-RU';
return date.toLocaleDateString(locale, {
day: 'numeric',
month: 'long',
year: 'numeric'

View File

@@ -63,7 +63,7 @@ export class SearchComponent implements OnDestroy {
performSearch(query: string): void {
if (!query.trim()) {
this.items.set([]);
this.hasMore.set(true);
this.hasMore.set(false);
this.totalResults.set(0);
return;
}

View File

@@ -7,19 +7,30 @@ import { LanguageService } from '../services/language.service';
})
export class LangRoutePipe implements PipeTransform {
private langService = inject(LanguageService);
private lastLang = '';
private lastInput: unknown = null;
private lastResult: string | (string | number)[] = '';
transform(value: string | (string | number)[]): string | (string | number)[] {
const lang = this.langService.currentLanguage();
// Short-circuit if nothing changed
if (lang === this.lastLang && value === this.lastInput) {
return this.lastResult;
}
this.lastLang = lang;
this.lastInput = value;
if (typeof value === 'string') {
return value === '/' ? `/${lang}` : `/${lang}${value}`;
}
if (Array.isArray(value) && value.length > 0) {
this.lastResult = value === '/' ? `/${lang}` : `/${lang}${value}`;
} else if (Array.isArray(value) && value.length > 0) {
const [first, ...rest] = value;
return [`/${lang}${first}`, ...rest];
this.lastResult = [`/${lang}${first}`, ...rest];
} else {
this.lastResult = value;
}
return value;
return this.lastResult;
}
}

View File

@@ -13,6 +13,7 @@ export class CartService {
private cartItems = signal<CartItem[]>([]);
private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp;
private addingItems = new Set<number>();
private initialized = false;
items = this.cartItems.asReadonly();
itemCount = computed(() => {
@@ -31,10 +32,12 @@ export class CartService {
constructor(private apiService: ApiService) {
this.loadCart();
// Auto-save whenever cart changes
// Auto-save whenever cart changes (skip the initial empty state)
effect(() => {
const items = this.cartItems();
this.saveToStorage(items);
if (this.initialized) {
this.saveToStorage(items);
}
});
}
@@ -67,9 +70,11 @@ export class CartService {
// No data in CloudStorage, try localStorage
this.loadFromLocalStorage();
}
this.initialized = true;
});
} else {
this.loadFromLocalStorage();
this.initialized = true;
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core';
import { Injectable, inject, DOCUMENT } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { environment } from '../../environments/environment';
import { Item } from '../models';
@@ -10,6 +10,7 @@ import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
export class SeoService {
private meta = inject(Meta);
private title = inject(Title);
private doc = inject(DOCUMENT);
private readonly siteUrl = `https://${environment.domain}`;
private readonly siteName = environment.brandFullName;
@@ -25,6 +26,7 @@ export class SeoService {
const titleText = `${item.name}${this.siteName}`;
this.title.setTitle(titleText);
this.setCanonical(itemUrl);
this.setOrUpdate([
// Open Graph
@@ -81,6 +83,7 @@ export class SeoService {
// Remove product-specific tags
this.meta.removeTag("property='product:price:amount'");
this.meta.removeTag("property='product:price:currency'");
this.removeCanonical();
}
private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void {
@@ -114,4 +117,19 @@ export class SeoService {
if (!text || text.length <= maxLength) return text || '';
return text.substring(0, maxLength - 1) + '…';
}
private setCanonical(url: string): void {
this.removeCanonical();
const link = this.doc.createElement('link');
link.setAttribute('rel', 'canonical');
link.setAttribute('href', url);
this.doc.head.appendChild(link);
}
private removeCanonical(): void {
const existing = this.doc.head.querySelector('link[rel="canonical"]');
if (existing) {
this.doc.head.removeChild(existing);
}
}
}