9 Commits

Author SHA1 Message Date
sdarbinyan
97214c3a90 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
2026-03-24 00:18:13 +04:00
sdarbinyan
0b3b2ee463 changes 2026-03-06 18:40:58 +04:00
sdarbinyan
c3e4e695eb changes and optimisations 2026-03-06 17:45:34 +04:00
sdarbinyan
c112aded47 added sceleton for loading 2026-03-06 17:22:35 +04:00
sdarbinyan
75f029b872 added condition 2026-03-06 16:59:01 +04:00
sdarbinyan
af78c053ba fixed design 2026-03-05 20:45:15 +04:00
sdarbinyan
7b18376d28 added info for legal 2026-03-05 20:23:42 +04:00
sdarbinyan
712281d2e8 closed en/am 2026-03-04 16:45:01 +04:00
sdarbinyan
0626dcbe46 changes in legal 2026-03-04 16:40:25 +04:00
37 changed files with 416 additions and 215 deletions

View File

@@ -176,26 +176,6 @@
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
}
}
}
}

View File

@@ -36,6 +36,9 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
# Brotli compression (if available)
# brotli on;

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,5 +1,4 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"name": "Novo Market - Интернет-магазин",
"short_name": "Novo",
"description": "Novo Market - ваш онлайн магазин качественных товаров с доставкой",
@@ -12,34 +11,10 @@
"categories": ["shopping", "lifestyle"],
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
"src": "assets/images/novo-favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "icons/icon-192x192.png",
@@ -47,12 +22,6 @@
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",

View File

@@ -11,34 +11,10 @@
"categories": ["shopping", "marketplace"],
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
"src": "assets/images/dexar-favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "icons/icon-192x192.png",
@@ -46,12 +22,6 @@
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",

View File

@@ -12,10 +12,10 @@
</div>
} @else {
<app-header></app-header>
@if (!isHomePage()) {
<app-back-button />
}
<main class="main-content">
@if (!isHomePage()) {
<app-back-button />
}
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>

View File

@@ -1,18 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
import { provideRouter } from '@angular/router';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])]
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});

View File

@@ -17,14 +17,16 @@ import { TranslateService } from '../../i18n/translate.service';
`,
styles: [`
.dexar-back-btn {
position: fixed;
top: 76px;
position: sticky;
top: 72px;
left: 20px;
z-index: 100;
background: none;
border: none;
cursor: pointer;
padding: 4px;
padding: 8px 4px;
margin-bottom: -40px;
width: fit-content;
transition: transform 0.2s ease;
svg path {
@@ -47,7 +49,7 @@ import { TranslateService } from '../../i18n/translate.service';
@media (max-width: 768px) {
.dexar-back-btn {
top: 68px;
top: 64px;
left: 12px;
svg {

View File

@@ -30,8 +30,8 @@
<app-region-selector />
<app-language-selector />
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()" [attr.aria-label]="'header.cart' | translate">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
@@ -41,7 +41,7 @@
}
</a>
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen">
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
<span></span>
<span></span>
<span></span>
@@ -118,7 +118,7 @@
</div>
<!-- Mobile Menu Toggle -->
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen">
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
<span></span>
<span></span>
<span></span>

View File

@@ -1,5 +1,5 @@
<div class="language-selector">
<button class="language-button" (click)="toggleDropdown()">
<div class="language-selector" role="listbox">
<button class="language-button" (click)="toggleDropdown()" (keydown)="onKeyDown($event)" aria-haspopup="listbox" [attr.aria-expanded]="dropdownOpen">
<img [src]="languageService.getCurrentLanguage()?.flagSvg"
[alt]="languageService.getCurrentLanguage()?.name"
class="language-flag">
@@ -13,6 +13,8 @@
@for (lang of languageService.languages; track lang.code) {
<button
class="language-option"
role="option"
[attr.aria-selected]="languageService.currentLanguage() === lang.code"
[class.active]="languageService.currentLanguage() === lang.code"
[class.disabled]="!lang.enabled"
[disabled]="!lang.enabled"

View File

@@ -44,6 +44,15 @@ export class LanguageSelectorComponent {
this.currencyOpen = false;
}
onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.dropdownOpen = false;
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.toggleDropdown();
}
}
@HostListener('document:click', ['$event'])
onClickOutside(event: Event): void {
if (!this.elementRef.nativeElement.contains(event.target)) {

View File

@@ -0,0 +1,19 @@
// Payment polling
export const PAYMENT_POLL_INTERVAL_MS = 5000;
export const PAYMENT_MAX_CHECKS = 36;
export const PAYMENT_TIMEOUT_CLOSE_MS = 3000;
export const PAYMENT_ERROR_CLOSE_MS = 4000;
export const LINK_COPIED_DURATION_MS = 2000;
// Infinite scroll
export const SCROLL_THRESHOLD_PX = 1200;
export const SCROLL_DEBOUNCE_MS = 100;
export const ITEMS_PER_PAGE = 50;
// Search
export const SEARCH_DEBOUNCE_MS = 300;
export const SEARCH_MIN_LENGTH = 3;
// Cache
export const CACHE_DURATION_MS = 5 * 60 * 1000;
export const CATEGORY_CACHE_DURATION_MS = 2 * 60 * 1000;

View File

@@ -2,8 +2,9 @@ import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_DURATION_MS, CATEGORY_CACHE_DURATION_MS } from '../config/constants';
const cache = new Map<string, { response: HttpResponse<unknown>, timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 минут
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
// Кэшируем только GET запросы
@@ -11,12 +12,16 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
return next(req);
}
// Кэшируем только запросы списка категорий (не товары категорий)
const shouldCache = req.url.match(/\/category$/) !== null;
if (!shouldCache) {
// Кэшируем списки категорий, товары категорий и отдельные товары
const isCategoryList = /\/category$/.test(req.url);
const isCategoryItems = /\/category\/\d+/.test(req.url);
const isItem = /\/item\/\d+/.test(req.url);
if (!isCategoryList && !isCategoryItems && !isItem) {
return next(req);
}
const ttl = isCategoryList ? CACHE_DURATION_MS : CATEGORY_CACHE_DURATION_MS;
// Cleanup expired entries before checking
cleanupExpiredCache();
@@ -25,7 +30,7 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
// Проверяем наличие и актуальность кэша
if (cachedResponse) {
const age = Date.now() - cachedResponse.timestamp;
if (age < CACHE_DURATION) {
if (age < ttl) {
return of(cachedResponse.response.clone());
} else {
cache.delete(req.url);
@@ -53,7 +58,7 @@ export function clearCache(): void {
function cleanupExpiredCache(): void {
const now = Date.now();
for (const [url, data] of cache.entries()) {
if (now - data.timestamp >= CACHE_DURATION) {
if (now - data.timestamp >= CACHE_DURATION_MS) {
cache.delete(url);
}
}

View File

@@ -13,6 +13,7 @@ import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTran
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service';
import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS, PAYMENT_ERROR_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants';
@Component({
selector: 'app-cart',
@@ -55,7 +56,7 @@ export class CartComponent implements OnDestroy {
emailSubmitting = signal<boolean>(false);
paidItems: CartItem[] = [];
maxChecks = 36; // 36 checks * 5 seconds = 180 seconds (3 minutes)
maxChecks = PAYMENT_MAX_CHECKS;
private pollingSubscription?: Subscription;
private closeTimeout?: ReturnType<typeof setTimeout>;
@@ -213,15 +214,17 @@ 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);
}, PAYMENT_ERROR_CLOSE_MS);
}
});
}
startPolling(): void {
this.pollingSubscription = interval(5000) // every 5 seconds
this.stopPolling();
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
.pipe(
take(this.maxChecks), // maximum 36 checks (3 minutes)
switchMap(() => {
@@ -245,17 +248,19 @@ 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);
}, PAYMENT_TIMEOUT_CLOSE_MS);
}
},
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);
}, PAYMENT_TIMEOUT_CLOSE_MS);
}
});
}
@@ -271,7 +276,7 @@ export class CartComponent implements OnDestroy {
if (url) {
navigator.clipboard.writeText(url).then(() => {
this.linkCopied.set(true);
setTimeout(() => this.linkCopied.set(false), 2000);
setTimeout(() => this.linkCopied.set(false), LINK_COPIED_DURATION_MS);
}).catch(err => {
console.error(this.i18n.t('cart.copyError'), err);
});

View File

@@ -9,7 +9,7 @@
@if (!error()) {
<div class="items-grid">
@for (item of items(); track trackByItemId($index, item)) {
<div class="item-card">
<div class="item-card" (mouseenter)="onItemHover(item.itemID)">
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
<div class="item-image">
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
@@ -52,19 +52,29 @@
</div>
</a>
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)">
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)" [attr.aria-label]="('category.addToCart' | translate) + ': ' + item.name">
{{ 'category.addToCart' | translate }}
</button>
</div>
}
</div>
@if (loading() && items().length > 0) {
<div class="loading-more">
<div class="spinner"></div>
<p>{{ 'category.loadingMore' | translate }}</p>
</div>
}
@if (loading() && items().length > 0) {
@for (i of skeletonSlots; track i) {
<div class="item-card skeleton-card">
<div class="item-link">
<div class="item-image skeleton-image"></div>
<div class="item-details">
<div class="skeleton-line skeleton-title"></div>
<div class="skeleton-line skeleton-rating"></div>
<div class="skeleton-line skeleton-price"></div>
<div class="skeleton-line skeleton-stock"></div>
</div>
</div>
<div class="skeleton-btn"></div>
</div>
}
}
</div>
@if (!hasMore() && items().length > 0) {
<div class="no-more">

View File

@@ -95,7 +95,7 @@
.items-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 30px;
margin-bottom: 40px;
width: 100%;
@@ -103,8 +103,10 @@
.item-card {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
@@ -139,7 +141,7 @@
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
background: #f0f0f0;
img {
width: 100%;
@@ -147,7 +149,7 @@
object-fit: contain;
background: white;
padding: 12px;
transition: transform 0.3s ease;
transition: transform 0.3s ease, opacity 0.3s ease;
}
&:hover img {
@@ -192,6 +194,7 @@
margin: 0;
line-height: 1.3;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
@@ -287,11 +290,6 @@
}
}
.loading-more {
text-align: center;
padding: 40px 20px;
}
.spinner {
width: 40px;
height: 40px;
@@ -312,24 +310,77 @@
padding: 40px 20px;
}
// Skeleton loading cards
.skeleton-card {
pointer-events: none;
.skeleton-image {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line {
border-radius: 6px;
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-title {
height: 16px;
width: 80%;
}
.skeleton-rating {
height: 12px;
width: 50%;
}
.skeleton-price {
height: 18px;
width: 40%;
margin-top: auto;
}
.skeleton-stock {
height: 6px;
width: 60px;
}
.skeleton-btn {
height: 42px;
background: linear-gradient(90deg, #5a8a85 25%, #497671 50%, #5a8a85 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0 0 13px 13px;
margin-top: -1px;
}
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// Responsive
@media (max-width: 1200px) {
.items-grid {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
}
}
@media (max-width: 992px) {
.items-grid {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
}
@media (max-width: 768px) {
.items-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
@@ -353,7 +404,7 @@
}
.items-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}

View File

@@ -2,12 +2,14 @@ import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStra
import { DecimalPipe } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService } from '../../services';
import { PrefetchService } from '../../services/prefetch.service';
import { Item } from '../../models';
import { Subscription } from 'rxjs';
import { getDiscountedPrice, getMainImage, trackByItemId, 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';
import { SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS, ITEMS_PER_PAGE } from '../../config/constants';
@Component({
selector: 'app-category',
@@ -24,7 +26,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
hasMore = signal(true);
private skip = 0;
private readonly count = 50;
private readonly count = ITEMS_PER_PAGE;
private isLoadingMore = false;
private routeSubscription?: Subscription;
private scrollTimeout?: ReturnType<typeof setTimeout>;
@@ -32,7 +34,8 @@ export class CategoryComponent implements OnInit, OnDestroy {
constructor(
private route: ActivatedRoute,
private apiService: ApiService,
private cartService: CartService
private cartService: CartService,
private prefetchService: PrefetchService
) {}
ngOnInit(): void {
@@ -91,12 +94,12 @@ export class CategoryComponent implements OnInit, OnDestroy {
this.scrollTimeout = setTimeout(() => {
const scrollPosition = window.innerHeight + window.scrollY;
const bottomPosition = document.documentElement.scrollHeight - 500;
const bottomPosition = document.documentElement.scrollHeight - SCROLL_THRESHOLD_PX;
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore() && !this.isLoadingMore) {
this.loadItems();
}
}, 100);
}, SCROLL_DEBOUNCE_MS);
}
addToCart(itemID: number, event: Event): void {
@@ -105,6 +108,11 @@ export class CategoryComponent implements OnInit, OnDestroy {
this.cartService.addItem(itemID);
}
onItemHover(itemID: number): void {
this.prefetchService.prefetchItem(itemID);
}
readonly skeletonSlots = Array.from({ length: 8 });
readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId;

View File

@@ -19,10 +19,23 @@
<app-items-carousel />
@if (loading()) {
<div class="novo-loading">
<div class="novo-spinner"></div>
<p>{{ 'home.loading' | translate }}</p>
</div>
<section class="novo-categories">
<div class="novo-section-header">
<div class="skeleton-line" style="height: 32px; width: 200px; margin: 0 auto 12px;"></div>
<div class="skeleton-line" style="height: 18px; width: 300px; margin: 0 auto;"></div>
</div>
<div class="novo-categories-grid">
@for (i of skeletonSlots; track i) {
<div class="novo-category-card skeleton-card">
<div class="novo-category-image skeleton-image"></div>
<div class="novo-category-info">
<div class="skeleton-line" style="height: 18px; width: 70%;"></div>
<div class="skeleton-line" style="height: 18px; width: 20px;"></div>
</div>
</div>
}
</div>
</section>
}
@if (error()) {
@@ -101,10 +114,20 @@
<app-items-carousel />
@if (loading()) {
<div class="dexar-loading">
<div class="dexar-spinner"></div>
<p>{{ 'home.loadingDexar' | translate }}</p>
</div>
<section class="dexar-categories">
<div class="skeleton-line" style="height: 36px; width: 220px; margin-bottom: 40px;"></div>
<div class="dexar-categories-grid">
@for (i of skeletonSlots; track i) {
<div class="dexar-category-card skeleton-card">
<div class="dexar-category-image skeleton-image"></div>
<div class="dexar-category-info">
<div class="skeleton-line" style="height: 16px; width: 75%;"></div>
<div class="skeleton-line" style="height: 12px; width: 40%; margin-top: 4px;"></div>
</div>
</div>
}
</div>
</section>
}
@if (error()) {

View File

@@ -896,3 +896,26 @@
transform: translateY(-2px);
}
}
// Skeleton loading cards
.skeleton-card {
pointer-events: none;
}
.skeleton-image {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line {
border-radius: 6px;
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

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,13 +14,14 @@ 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[]>([]);
wideCategories = signal<Set<number>>(new Set());
loading = signal(true);
error = signal<string | null>(null);
readonly skeletonSlots = Array.from({ length: 6 });
// Memoized computed values for performance
topLevelCategories = computed(() => {
@@ -56,6 +57,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 +93,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 +113,7 @@ export class HomeComponent implements OnInit {
});
}
};
img.onerror = () => this.pendingImages.delete(img);
img.src = cat.wideBanner;
});
}

View File

@@ -1,4 +1,4 @@
<div class="legal-page">
<div class="legal-page">
<div class="legal-container">
<h1>About the company LLC «INT FIN LOGISTIC»</h1>
@@ -65,7 +65,7 @@
<p><strong>Director:</strong> Оганнисян Ашот Рафикович</p>
<p><strong>Legal address:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
<p><strong>Office in Armenia:</strong><br>0033, Ереван, улица Братьев Орбели, 47</p>
<p><strong>Office in Russia:</strong><br>121059, Москва, наб. Тараса Шевченко, 3к2</p>
<p><strong>Key details:</strong><br>ИНН (RF): 9909697628<br>ИНН (Armenia): 03033502<br>КПП: 770287001<br>ОГРН: 85.110.1408711</p>
<p><strong>Banking details:</strong><br>Bank: АО "Райффайзенбанк"<br>Settlement account: 40807810500000002376<br>Correspondent account: 30101810200000000700<br>БИК: 044525700</p>
<p><strong>Contact information:</strong><br>Phone (Russia): +7 (926) 459-31-57<br>Phone (Armenia): +374 94 86 18 16<br>Email: info&#64;dexarmarket.ru<br>Website: www.dexarmarket.ru</p>

View File

@@ -1,4 +1,4 @@
<div class="legal-page">
<div class="legal-page">
<div class="legal-container">
<h1>«ИНТ ФИН ЛОГИСТИК» ՍՊԸ ընկերության մասին</h1>
@@ -65,7 +65,6 @@
<p><strong>Տնօրեն՝</strong> Оганнисян Ашот Рафикович</p>
<p><strong>Իրավաբանական հասցե՝</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
<p><strong>Գրասենյակ Հայաստանում՝</strong><br>0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</p>
<p><strong>Գրասենյակ Ռուսաստանում՝</strong><br>121059, Москва, наб. Тараса Шевченко, 3к2</p>
<p><strong>Հիմնական վավերապայմանները՝</strong><br>ՀՍՀ (ՌՄ)՝ 9909697628<br>ՀՍՀ (Հայաստան)՝ 03033502<br>ԿՊՊ՝ 770287001<br>ՕԳՌՆ՝ 85.110.1408711</p>
<p><strong>Բանկային վավերապայմանները՝</strong><br>Բանկ՝ АО "Райффайзенбанк"<br>Հաշվարկային հաշիվ՝ 40807810500000002376<br>Թղթակցային հաշիվ՝ 30101810200000000700<br>ԲԻԿ՝ 044525700</p>
<p><strong>Կապի տեղեկատվություն՝</strong><br>Հեռախոս (Ռուսաստան)՝ +7 (926) 459-31-57<br>Հեռախոս (Հայաստան)՝ +374 94 86 18 16<br>Էլ. փոստ՝ info&#64;dexarmarket.ru<br>Կայք՝ www.dexarmarket.ru</p>

View File

@@ -1,4 +1,4 @@
<div class="legal-page">
<div class="legal-page">
<div class="legal-container">
<h1>О компании ООО «ИНТ ФИН ЛОГИСТИК»</h1>
@@ -65,7 +65,7 @@
<p><strong>Директор:</strong> Оганнисян Ашот Рафикович</p>
<p><strong>Юридический адрес:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
<p><strong>Офис в Армении:</strong><br>0033, Ереван, улица Братьев Орбели, 47</p>
<p><strong>Офис в России:</strong><br>121059, Москва, наб. Тараса Шевченко, 3к2</p>
<p><strong>Основные реквизиты:</strong><br>ИНН (РФ): 9909697628<br>ИНН (Армения): 03033502<br>КПП: 770287001<br>ОГРН: 85.110.1408711</p>
<p><strong>Банковские реквизиты:</strong><br>Банк: АО "Райффайзенбанк"<br>Расчетный счет: 40807810500000002376<br>Корр. счет: 30101810200000000700<br>БИК: 044525700</p>
<p><strong>Контактная информация:</strong><br>Телефон (Россия): +7 (926) 459-31-57<br>Телефон (Армения): +374 94 86 18 16<br>Email: info&#64;dexarmarket.ru<br>Сайт: www.dexarmarket.ru</p>

View File

@@ -1,4 +1,4 @@
<div class="legal-page">
<div class="legal-page">
<div class="legal-container">
<h1>Contacts</h1>
@@ -35,7 +35,7 @@
<section class="legal-section">
<h2>Office Addresses</h2>
<p><strong>Office in Armenia:</strong> 0033, Yerevan, Orbeli Brothers Street, 47</p>
<p><strong>Office in Russia:</strong> 121059, Moscow, Taras Shevchenko Embankment, 3/2</p>
</section>
<section class="legal-section">

View File

@@ -1,4 +1,4 @@
<div class="legal-page">
<div class="legal-page">
<div class="legal-container">
<h1>Կապ</h1>
@@ -35,8 +35,7 @@
<section class="legal-section">
<h2>Գրասենյակների հասցեներ</h2>
<p><strong>Գրասենյակ Հայաստանում՝</strong> 0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</p>
<p><strong>Գրասենյակ Ռուսաստանում՝</strong> 121059, Մոսկվա, Տարաս Շևչենկոի փողոց, 3կ2</p>
</section>
</section>
<section class="legal-section">
<h2>Տեխնիկական աջակցություն</h2>

View File

@@ -1,4 +1,4 @@
<div class="legal-page">
<div class="legal-page">
<div class="legal-container">
<h1>Контакты</h1>
@@ -35,7 +35,7 @@
<section class="legal-section">
<h2>Адреса офисов</h2>
<p><strong>Офис в Армении:</strong> 0033, Ереван, улица Братьев Орбели, 47</p>
<p><strong>Офис в России:</strong> 121059, Москва, наб. Тараса Шевченко, 3к2</p>
</section>
<section class="legal-section">

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);
}
@@ -492,7 +494,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

@@ -56,7 +56,7 @@
@if (items().length > 0) {
<div class="items-grid">
@for (item of items(); track trackByItemId($index, item)) {
<div class="item-card">
<div class="item-card" (mouseenter)="onItemHover(item.itemID)">
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
<div class="item-image">
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
@@ -105,19 +105,29 @@
</div>
</a>
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)">
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)" [attr.aria-label]="('search.addToCart' | translate) + ': ' + item.name">
{{ 'search.addToCart' | translate }}
</button>
</div>
}
</div>
@if (loading() && items().length > 0) {
<div class="loading-more">
<div class="spinner"></div>
<p>{{ 'search.loadingMore' | translate }}</p>
</div>
}
@if (loading() && items().length > 0) {
@for (i of skeletonSlots; track i) {
<div class="item-card skeleton-card">
<div class="item-link">
<div class="item-image skeleton-image"></div>
<div class="item-details">
<div class="skeleton-line skeleton-title"></div>
<div class="skeleton-line skeleton-rating"></div>
<div class="skeleton-line skeleton-price"></div>
<div class="skeleton-line skeleton-stock"></div>
</div>
</div>
<div class="skeleton-btn"></div>
</div>
}
}
@if (!hasMore() && items().length > 0) {
<div class="no-more">

View File

@@ -344,6 +344,59 @@
text-align: center;
}
// Skeleton loading cards
.skeleton-card {
pointer-events: none;
.skeleton-image {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line {
border-radius: 6px;
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-title {
height: 16px;
width: 80%;
}
.skeleton-rating {
height: 12px;
width: 50%;
}
.skeleton-price {
height: 18px;
width: 40%;
margin-top: auto;
}
.skeleton-stock {
height: 6px;
width: 60px;
}
.skeleton-btn {
height: 42px;
background: linear-gradient(90deg, #5a8a85 25%, #497671 50%, #5a8a85 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0 0 13px 13px;
margin-top: -1px;
}
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (max-width: 768px) {
.search-header h1 {
font-size: 1.5rem;

View File

@@ -3,6 +3,7 @@ import { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { ApiService, CartService } from '../../services';
import { PrefetchService } from '../../services/prefetch.service';
import { Item } from '../../models';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
@@ -11,6 +12,7 @@ import { LanguageService } from '../../services/language.service';
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service';
import { SEARCH_DEBOUNCE_MS, ITEMS_PER_PAGE, SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS } from '../../config/constants';
@Component({
selector: 'app-search',
@@ -28,7 +30,7 @@ export class SearchComponent implements OnDestroy {
totalResults = signal<number>(0);
private skip = 0;
private readonly count = 50;
private readonly count = ITEMS_PER_PAGE;
private isLoadingMore = false;
private searchSubject = new Subject<string>();
private searchSubscription: Subscription;
@@ -36,11 +38,12 @@ export class SearchComponent implements OnDestroy {
constructor(
private apiService: ApiService,
private cartService: CartService
private cartService: CartService,
private prefetchService: PrefetchService
) {
this.searchSubscription = this.searchSubject
.pipe(
debounceTime(300),
debounceTime(SEARCH_DEBOUNCE_MS),
distinctUntilChanged()
)
.subscribe(query => {
@@ -64,7 +67,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;
}
@@ -120,12 +123,12 @@ export class SearchComponent implements OnDestroy {
this.scrollTimeout = setTimeout(() => {
const scrollPosition = window.innerHeight + window.scrollY;
const bottomPosition = document.documentElement.scrollHeight - 500;
const bottomPosition = document.documentElement.scrollHeight - SCROLL_THRESHOLD_PX;
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore()) {
this.loadResults();
}
}, 100);
}, SCROLL_DEBOUNCE_MS);
}
addToCart(itemID: number, event: Event): void {
@@ -134,6 +137,11 @@ export class SearchComponent implements OnDestroy {
this.cartService.addItem(itemID);
}
onItemHover(itemID: number): void {
this.prefetchService.prefetchItem(itemID);
}
readonly skeletonSlots = Array.from({ length: 8 });
readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage;
readonly trackByItemId = trackByItemId;

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

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Observable, timer } from 'rxjs';
import { map, retry } from 'rxjs/operators';
import { Category, Item, Subcategory } from '../models';
import { environment } from '../../environments/environment';
@@ -11,6 +11,11 @@ import { environment } from '../../environments/environment';
export class ApiService {
private readonly baseUrl = environment.apiUrl;
private readonly retryConfig = {
count: 2,
delay: (error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
};
constructor(private http: HttpClient) {}
/**
@@ -168,7 +173,7 @@ export class ApiService {
getCategories(): Observable<Category[]> {
return this.http.get<any[]>(`${this.baseUrl}/category`)
.pipe(map(cats => this.normalizeCategories(cats)));
.pipe(retry(this.retryConfig), map(cats => this.normalizeCategories(cats)));
}
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
@@ -176,12 +181,12 @@ export class ApiService {
.set('count', count.toString())
.set('skip', skip.toString());
return this.http.get<any[]>(`${this.baseUrl}/category/${categoryID}`, { params })
.pipe(map(items => this.normalizeItems(items)));
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
}
getItem(itemID: number): Observable<Item> {
return this.http.get<any>(`${this.baseUrl}/item/${itemID}`)
.pipe(map(item => this.normalizeItem(item)));
.pipe(retry(this.retryConfig), map(item => this.normalizeItem(item)));
}
searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> {
@@ -191,6 +196,7 @@ export class ApiService {
.set('skip', skip.toString());
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
.pipe(
retry(this.retryConfig),
map(response => ({
items: this.normalizeItems(response?.items || []),
total: response?.total || 0
@@ -298,6 +304,6 @@ export class ApiService {
params = params.set('category', categoryID.toString());
}
return this.http.get<any[]>(`${this.baseUrl}/randomitems`, { params })
.pipe(map(items => this.normalizeItems(items)));
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
}
}

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

@@ -24,8 +24,8 @@ export class LanguageService {
languages: Language[] = [
{ code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true },
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: true },
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: true }
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: false },
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: false }
];
currencies: Currency[] = [

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { ApiService } from './api.service';
@Injectable({ providedIn: 'root' })
export class PrefetchService {
private prefetched = new Set<number>();
constructor(private api: ApiService) {}
prefetchItem(itemID: number): void {
if (this.prefetched.has(itemID)) return;
this.prefetched.add(itemID);
this.api.getItem(itemID).subscribe();
}
}

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);
}
}
}