Compare commits
23 Commits
back-offic
...
3a8bc2f893
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8bc2f893 | ||
|
|
d29de100c6 | ||
|
|
97214c3a90 | ||
|
|
0b3b2ee463 | ||
|
|
c3e4e695eb | ||
|
|
c112aded47 | ||
|
|
75f029b872 | ||
|
|
f823df7e15 | ||
|
|
af78c053ba | ||
|
|
4ef4223367 | ||
|
|
7b18376d28 | ||
|
|
c64b9cfee8 | ||
|
|
712281d2e8 | ||
|
|
0626dcbe46 | ||
|
|
d288a5fb3c | ||
|
|
75b45abe4f | ||
|
|
2bd98b29eb | ||
|
|
82cbf07120 | ||
|
|
e07356a700 | ||
|
|
5068a3a114 | ||
|
|
333ea45c38 | ||
|
|
b22390f3eb | ||
|
|
3f285ca15f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -38,7 +38,7 @@ yarn-error.log
|
|||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
/public/images/
|
||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
21
angular.json
21
angular.json
@@ -176,28 +176,9 @@
|
|||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular/build: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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" 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 compression (if available)
|
||||||
# brotli on;
|
# brotli on;
|
||||||
|
|||||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -9580,3 +9580,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,3 +47,4 @@
|
|||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
|
||||||
"name": "Novo Market - Интернет-магазин",
|
"name": "Novo Market - Интернет-магазин",
|
||||||
"short_name": "Novo",
|
"short_name": "Novo",
|
||||||
"description": "Novo Market - ваш онлайн магазин качественных товаров с доставкой",
|
"description": "Novo Market - ваш онлайн магазин качественных товаров с доставкой",
|
||||||
@@ -12,34 +11,10 @@
|
|||||||
"categories": ["shopping", "lifestyle"],
|
"categories": ["shopping", "lifestyle"],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "icons/icon-72x72.png",
|
"src": "assets/images/novo-favicon.svg",
|
||||||
"sizes": "72x72",
|
"sizes": "any",
|
||||||
"type": "image/png",
|
"type": "image/svg+xml",
|
||||||
"purpose": "maskable any"
|
"purpose": "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": "icons/icon-192x192.png",
|
"src": "icons/icon-192x192.png",
|
||||||
@@ -47,12 +22,6 @@
|
|||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"src": "icons/icon-384x384.png",
|
|
||||||
"sizes": "384x384",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable any"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"src": "icons/icon-512x512.png",
|
"src": "icons/icon-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
|
|||||||
@@ -11,34 +11,10 @@
|
|||||||
"categories": ["shopping", "marketplace"],
|
"categories": ["shopping", "marketplace"],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "icons/icon-72x72.png",
|
"src": "assets/images/dexar-favicon.svg",
|
||||||
"sizes": "72x72",
|
"sizes": "any",
|
||||||
"type": "image/png",
|
"type": "image/svg+xml",
|
||||||
"purpose": "maskable any"
|
"purpose": "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": "icons/icon-192x192.png",
|
"src": "icons/icon-192x192.png",
|
||||||
@@ -46,12 +22,6 @@
|
|||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"src": "icons/icon-384x384.png",
|
|
||||||
"sizes": "384x384",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable any"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"src": "icons/icon-512x512.png",
|
"src": "icons/icon-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<app-header></app-header>
|
<app-header></app-header>
|
||||||
@if (!isHomePage()) {
|
|
||||||
<app-back-button />
|
|
||||||
}
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
|
@if (!isHomePage()) {
|
||||||
|
<app-back-button />
|
||||||
|
}
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
<app-footer></app-footer>
|
<app-footer></app-footer>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -17,14 +17,16 @@ import { TranslateService } from '../../i18n/translate.service';
|
|||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.dexar-back-btn {
|
.dexar-back-btn {
|
||||||
position: fixed;
|
position: sticky;
|
||||||
top: 76px;
|
top: 72px;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 8px 4px;
|
||||||
|
margin-bottom: -40px;
|
||||||
|
width: fit-content;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
svg path {
|
svg path {
|
||||||
@@ -47,7 +49,7 @@ import { TranslateService } from '../../i18n/translate.service';
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dexar-back-btn {
|
.dexar-back-btn {
|
||||||
top: 68px;
|
top: 64px;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@
|
|||||||
<app-region-selector />
|
<app-region-selector />
|
||||||
<app-language-selector />
|
<app-language-selector />
|
||||||
|
|
||||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()">
|
<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">
|
<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="9" cy="21" r="1"></circle>
|
||||||
<circle cx="20" 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>
|
<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>
|
</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>
|
<span></span>
|
||||||
<span></span>
|
<span></span>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Menu Toggle -->
|
<!-- 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>
|
<span></span>
|
||||||
<span></span>
|
<span></span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="language-selector">
|
<div class="language-selector" role="listbox">
|
||||||
<button class="language-button" (click)="toggleDropdown()">
|
<button class="language-button" (click)="toggleDropdown()" (keydown)="onKeyDown($event)" aria-haspopup="listbox" [attr.aria-expanded]="dropdownOpen">
|
||||||
<img [src]="languageService.getCurrentLanguage()?.flagSvg"
|
<img [src]="languageService.getCurrentLanguage()?.flagSvg"
|
||||||
[alt]="languageService.getCurrentLanguage()?.name"
|
[alt]="languageService.getCurrentLanguage()?.name"
|
||||||
class="language-flag">
|
class="language-flag">
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
@for (lang of languageService.languages; track lang.code) {
|
@for (lang of languageService.languages; track lang.code) {
|
||||||
<button
|
<button
|
||||||
class="language-option"
|
class="language-option"
|
||||||
|
role="option"
|
||||||
|
[attr.aria-selected]="languageService.currentLanguage() === lang.code"
|
||||||
[class.active]="languageService.currentLanguage() === lang.code"
|
[class.active]="languageService.currentLanguage() === lang.code"
|
||||||
[class.disabled]="!lang.enabled"
|
[class.disabled]="!lang.enabled"
|
||||||
[disabled]="!lang.enabled"
|
[disabled]="!lang.enabled"
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ export class LanguageSelectorComponent {
|
|||||||
this.currencyOpen = false;
|
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'])
|
@HostListener('document:click', ['$event'])
|
||||||
onClickOutside(event: Event): void {
|
onClickOutside(event: Event): void {
|
||||||
if (!this.elementRef.nativeElement.contains(event.target)) {
|
if (!this.elementRef.nativeElement.contains(event.target)) {
|
||||||
|
|||||||
19
src/app/config/constants.ts
Normal file
19
src/app/config/constants.ts
Normal 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;
|
||||||
@@ -2,8 +2,9 @@ import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
|||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
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 = new Map<string, { response: HttpResponse<unknown>, timestamp: number }>();
|
||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 минут
|
|
||||||
|
|
||||||
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
// Кэшируем только GET запросы
|
// Кэшируем только GET запросы
|
||||||
@@ -11,12 +12,16 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кэшируем только запросы списка категорий (не товары категорий)
|
// Кэшируем списки категорий, товары категорий и отдельные товары
|
||||||
const shouldCache = req.url.match(/\/category$/) !== null;
|
const isCategoryList = /\/category$/.test(req.url);
|
||||||
if (!shouldCache) {
|
const isCategoryItems = /\/category\/\d+/.test(req.url);
|
||||||
|
const isItem = /\/item\/\d+/.test(req.url);
|
||||||
|
if (!isCategoryList && !isCategoryItems && !isItem) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ttl = isCategoryList ? CACHE_DURATION_MS : CATEGORY_CACHE_DURATION_MS;
|
||||||
|
|
||||||
// Cleanup expired entries before checking
|
// Cleanup expired entries before checking
|
||||||
cleanupExpiredCache();
|
cleanupExpiredCache();
|
||||||
|
|
||||||
@@ -25,7 +30,7 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
// Проверяем наличие и актуальность кэша
|
// Проверяем наличие и актуальность кэша
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
const age = Date.now() - cachedResponse.timestamp;
|
const age = Date.now() - cachedResponse.timestamp;
|
||||||
if (age < CACHE_DURATION) {
|
if (age < ttl) {
|
||||||
return of(cachedResponse.response.clone());
|
return of(cachedResponse.response.clone());
|
||||||
} else {
|
} else {
|
||||||
cache.delete(req.url);
|
cache.delete(req.url);
|
||||||
@@ -53,7 +58,7 @@ export function clearCache(): void {
|
|||||||
function cleanupExpiredCache(): void {
|
function cleanupExpiredCache(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [url, data] of cache.entries()) {
|
for (const [url, data] of cache.entries()) {
|
||||||
if (now - data.timestamp >= CACHE_DURATION) {
|
if (now - data.timestamp >= CACHE_DURATION_MS) {
|
||||||
cache.delete(url);
|
cache.delete(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTran
|
|||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../i18n/translate.service';
|
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({
|
@Component({
|
||||||
selector: 'app-cart',
|
selector: 'app-cart',
|
||||||
@@ -55,7 +56,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
emailSubmitting = signal<boolean>(false);
|
emailSubmitting = signal<boolean>(false);
|
||||||
paidItems: CartItem[] = [];
|
paidItems: CartItem[] = [];
|
||||||
|
|
||||||
maxChecks = 36; // 36 checks * 5 seconds = 180 seconds (3 minutes)
|
maxChecks = PAYMENT_MAX_CHECKS;
|
||||||
private pollingSubscription?: Subscription;
|
private pollingSubscription?: Subscription;
|
||||||
private closeTimeout?: ReturnType<typeof setTimeout>;
|
private closeTimeout?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
@@ -213,15 +214,17 @@ export class CartComponent implements OnDestroy {
|
|||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Error creating payment:', err);
|
console.error('Error creating payment:', err);
|
||||||
this.paymentStatus.set('timeout');
|
this.paymentStatus.set('timeout');
|
||||||
|
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||||
this.closeTimeout = setTimeout(() => {
|
this.closeTimeout = setTimeout(() => {
|
||||||
this.closePaymentPopup();
|
this.closePaymentPopup();
|
||||||
}, 4000);
|
}, PAYMENT_ERROR_CLOSE_MS);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startPolling(): void {
|
startPolling(): void {
|
||||||
this.pollingSubscription = interval(5000) // every 5 seconds
|
this.stopPolling();
|
||||||
|
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
|
||||||
.pipe(
|
.pipe(
|
||||||
take(this.maxChecks), // maximum 36 checks (3 minutes)
|
take(this.maxChecks), // maximum 36 checks (3 minutes)
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
@@ -245,17 +248,19 @@ export class CartComponent implements OnDestroy {
|
|||||||
if (this.paymentStatus() === 'waiting') {
|
if (this.paymentStatus() === 'waiting') {
|
||||||
this.paymentStatus.set('timeout');
|
this.paymentStatus.set('timeout');
|
||||||
// Close popup after showing timeout message
|
// Close popup after showing timeout message
|
||||||
|
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||||
this.closeTimeout = setTimeout(() => {
|
this.closeTimeout = setTimeout(() => {
|
||||||
this.closePaymentPopup();
|
this.closePaymentPopup();
|
||||||
}, 3000);
|
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Error checking payment status:', err);
|
console.error('Error checking payment status:', err);
|
||||||
// Continue checking even on error until time runs out
|
// Continue checking even on error until time runs out
|
||||||
|
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||||
this.closeTimeout = setTimeout(() => {
|
this.closeTimeout = setTimeout(() => {
|
||||||
this.closePaymentPopup();
|
this.closePaymentPopup();
|
||||||
}, 3000);
|
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -271,7 +276,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
if (url) {
|
if (url) {
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
this.linkCopied.set(true);
|
this.linkCopied.set(true);
|
||||||
setTimeout(() => this.linkCopied.set(false), 2000);
|
setTimeout(() => this.linkCopied.set(false), LINK_COPIED_DURATION_MS);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error(this.i18n.t('cart.copyError'), err);
|
console.error(this.i18n.t('cart.copyError'), err);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
@if (!error()) {
|
@if (!error()) {
|
||||||
<div class="items-grid">
|
<div class="items-grid">
|
||||||
@for (item of items(); track trackByItemId($index, item)) {
|
@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">
|
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
||||||
<div class="item-image">
|
<div class="item-image">
|
||||||
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
|
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
|
||||||
@@ -52,19 +52,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</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 }}
|
{{ 'category.addToCart' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loading() && items().length > 0) {
|
@if (loading() && items().length > 0) {
|
||||||
<div class="loading-more">
|
@for (i of skeletonSlots; track i) {
|
||||||
<div class="spinner"></div>
|
<div class="item-card skeleton-card">
|
||||||
<p>{{ 'category.loadingMore' | translate }}</p>
|
<div class="item-link">
|
||||||
</div>
|
<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) {
|
@if (!hasMore() && items().length > 0) {
|
||||||
<div class="no-more">
|
<div class="no-more">
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
|
|
||||||
.items-grid {
|
.items-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -103,8 +103,10 @@
|
|||||||
|
|
||||||
.item-card {
|
.item-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -139,7 +141,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #f5f5f5;
|
background: #f0f0f0;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -147,7 +149,7 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: white;
|
background: white;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover img {
|
&:hover img {
|
||||||
@@ -192,6 +194,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
|
line-clamp: 2;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -287,11 +290,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-more {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -312,24 +310,77 @@
|
|||||||
padding: 40px 20px;
|
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
|
// Responsive
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.items-grid {
|
.items-grid {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
.items-grid {
|
.items-grid {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.items-grid {
|
.items-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +404,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.items-grid {
|
.items-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStra
|
|||||||
import { DecimalPipe } from '@angular/common';
|
import { DecimalPipe } from '@angular/common';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { ApiService, CartService } from '../../services';
|
import { ApiService, CartService } from '../../services';
|
||||||
|
import { PrefetchService } from '../../services/prefetch.service';
|
||||||
import { Item } from '../../models';
|
import { Item } from '../../models';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||||
import { LanguageService } from '../../services/language.service';
|
import { LanguageService } from '../../services/language.service';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
|
import { SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS, ITEMS_PER_PAGE } from '../../config/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-category',
|
selector: 'app-category',
|
||||||
@@ -24,7 +26,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
|||||||
hasMore = signal(true);
|
hasMore = signal(true);
|
||||||
|
|
||||||
private skip = 0;
|
private skip = 0;
|
||||||
private readonly count = 50;
|
private readonly count = ITEMS_PER_PAGE;
|
||||||
private isLoadingMore = false;
|
private isLoadingMore = false;
|
||||||
private routeSubscription?: Subscription;
|
private routeSubscription?: Subscription;
|
||||||
private scrollTimeout?: ReturnType<typeof setTimeout>;
|
private scrollTimeout?: ReturnType<typeof setTimeout>;
|
||||||
@@ -32,7 +34,8 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
|||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private cartService: CartService
|
private cartService: CartService,
|
||||||
|
private prefetchService: PrefetchService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -91,12 +94,12 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.scrollTimeout = setTimeout(() => {
|
this.scrollTimeout = setTimeout(() => {
|
||||||
const scrollPosition = window.innerHeight + window.scrollY;
|
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) {
|
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore() && !this.isLoadingMore) {
|
||||||
this.loadItems();
|
this.loadItems();
|
||||||
}
|
}
|
||||||
}, 100);
|
}, SCROLL_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
addToCart(itemID: number, event: Event): void {
|
addToCart(itemID: number, event: Event): void {
|
||||||
@@ -105,6 +108,11 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
|||||||
this.cartService.addItem(itemID);
|
this.cartService.addItem(itemID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onItemHover(itemID: number): void {
|
||||||
|
this.prefetchService.prefetchItem(itemID);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly skeletonSlots = Array.from({ length: 8 });
|
||||||
readonly getDiscountedPrice = getDiscountedPrice;
|
readonly getDiscountedPrice = getDiscountedPrice;
|
||||||
readonly getMainImage = getMainImage;
|
readonly getMainImage = getMainImage;
|
||||||
readonly trackByItemId = trackByItemId;
|
readonly trackByItemId = trackByItemId;
|
||||||
|
|||||||
@@ -19,10 +19,23 @@
|
|||||||
<app-items-carousel />
|
<app-items-carousel />
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="novo-loading">
|
<section class="novo-categories">
|
||||||
<div class="novo-spinner"></div>
|
<div class="novo-section-header">
|
||||||
<p>{{ 'home.loading' | translate }}</p>
|
<div class="skeleton-line" style="height: 32px; width: 200px; margin: 0 auto 12px;"></div>
|
||||||
</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()) {
|
@if (error()) {
|
||||||
@@ -101,10 +114,20 @@
|
|||||||
<app-items-carousel />
|
<app-items-carousel />
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="dexar-loading">
|
<section class="dexar-categories">
|
||||||
<div class="dexar-spinner"></div>
|
<div class="skeleton-line" style="height: 36px; width: 220px; margin-bottom: 40px;"></div>
|
||||||
<p>{{ 'home.loadingDexar' | translate }}</p>
|
<div class="dexar-categories-grid">
|
||||||
</div>
|
@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()) {
|
@if (error()) {
|
||||||
|
|||||||
@@ -896,3 +896,26 @@
|
|||||||
transform: translateY(-2px);
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { Router, RouterLink } from '@angular/router';
|
||||||
import { ApiService, LanguageService } from '../../services';
|
import { ApiService, LanguageService } from '../../services';
|
||||||
import { Category } from '../../models';
|
import { Category } from '../../models';
|
||||||
@@ -14,13 +14,14 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
|||||||
styleUrls: ['./home.component.scss'],
|
styleUrls: ['./home.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class HomeComponent implements OnInit {
|
export class HomeComponent implements OnInit, OnDestroy {
|
||||||
brandName = environment.brandFullName;
|
brandName = environment.brandFullName;
|
||||||
isnovo = environment.theme === 'novo';
|
isnovo = environment.theme === 'novo';
|
||||||
categories = signal<Category[]>([]);
|
categories = signal<Category[]>([]);
|
||||||
wideCategories = signal<Set<number>>(new Set());
|
wideCategories = signal<Set<number>>(new Set());
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
readonly skeletonSlots = Array.from({ length: 6 });
|
||||||
|
|
||||||
// Memoized computed values for performance
|
// Memoized computed values for performance
|
||||||
topLevelCategories = computed(() => {
|
topLevelCategories = computed(() => {
|
||||||
@@ -56,6 +57,14 @@ export class HomeComponent implements OnInit {
|
|||||||
this.loadCategories();
|
this.loadCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.pendingImages.forEach(img => {
|
||||||
|
img.onload = null;
|
||||||
|
img.onerror = null;
|
||||||
|
});
|
||||||
|
this.pendingImages.clear();
|
||||||
|
}
|
||||||
|
|
||||||
loadCategories(): void {
|
loadCategories(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.apiService.getCategories().subscribe({
|
this.apiService.getCategories().subscribe({
|
||||||
@@ -84,13 +93,17 @@ export class HomeComponent implements OnInit {
|
|||||||
return this.wideCategories().has(categoryID);
|
return this.wideCategories().has(categoryID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pendingImages = new Set<HTMLImageElement>();
|
||||||
|
|
||||||
private detectWideImages(categories: Category[]): void {
|
private detectWideImages(categories: Category[]): void {
|
||||||
const topLevel = categories.filter(c => c.parentID === 0);
|
const topLevel = categories.filter(c => c.parentID === 0);
|
||||||
topLevel.forEach(cat => {
|
topLevel.forEach(cat => {
|
||||||
if (!cat.wideBanner) return;
|
if (!cat.wideBanner) return;
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
this.pendingImages.add(img);
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
|
this.pendingImages.delete(img);
|
||||||
const ratio = img.naturalWidth / img.naturalHeight;
|
const ratio = img.naturalWidth / img.naturalHeight;
|
||||||
if (ratio > 2) {
|
if (ratio > 2) {
|
||||||
this.wideCategories.update(set => {
|
this.wideCategories.update(set => {
|
||||||
@@ -100,6 +113,7 @@ export class HomeComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
img.onerror = () => this.pendingImages.delete(img);
|
||||||
img.src = cat.wideBanner;
|
img.src = cat.wideBanner;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="legal-page">
|
<div class="legal-page">
|
||||||
<div class="legal-container">
|
<div class="legal-container">
|
||||||
<h1>About the company LLC «INT FIN LOGISTIC»</h1>
|
<h1>About the company LLC «INT FIN LOGISTIC»</h1>
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<p><strong>Director:</strong> Оганнисян Ашот Рафикович</p>
|
<p><strong>Director:</strong> Оганнисян Ашот Рафикович</p>
|
||||||
<p><strong>Legal address:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</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 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>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>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@dexarmarket.ru<br>Website: www.dexarmarket.ru</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@dexarmarket.ru<br>Website: www.dexarmarket.ru</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="legal-page">
|
<div class="legal-page">
|
||||||
<div class="legal-container">
|
<div class="legal-container">
|
||||||
<h1>«ИНТ ФИН ЛОГИСТИК» ՍՊԸ ընկերության մասին</h1>
|
<h1>«ИНТ ФИН ЛОГИСТИК» ՍՊԸ ընկերության մասին</h1>
|
||||||
|
|
||||||
@@ -65,7 +65,6 @@
|
|||||||
<p><strong>Տնօրեն՝</strong> Оганнисян Ашот Рафикович</p>
|
<p><strong>Տնօրեն՝</strong> Оганнисян Ашот Рафикович</p>
|
||||||
<p><strong>Իրավաբանական հասցե՝</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
|
<p><strong>Իրավաբանական հասցե՝</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
|
||||||
<p><strong>Գրասենյակ Հայաստանում՝</strong><br>0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</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>ՀՍՀ (ՌՄ)՝ 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>Բանկ՝ АО "Райффайзенбанк"<br>Հաշվարկային հաշիվ՝ 40807810500000002376<br>Թղթակցային հաշիվ՝ 30101810200000000700<br>ԲԻԿ՝ 044525700</p>
|
||||||
<p><strong>Կապի տեղեկատվություն՝</strong><br>Հեռախոս (Ռուսաստան)՝ +7 (926) 459-31-57<br>Հեռախոս (Հայաստան)՝ +374 94 86 18 16<br>Էլ. փոստ՝ info@dexarmarket.ru<br>Կայք՝ www.dexarmarket.ru</p>
|
<p><strong>Կապի տեղեկատվություն՝</strong><br>Հեռախոս (Ռուսաստան)՝ +7 (926) 459-31-57<br>Հեռախոս (Հայաստան)՝ +374 94 86 18 16<br>Էլ. փոստ՝ info@dexarmarket.ru<br>Կայք՝ www.dexarmarket.ru</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="legal-page">
|
<div class="legal-page">
|
||||||
<div class="legal-container">
|
<div class="legal-container">
|
||||||
<h1>О компании ООО «ИНТ ФИН ЛОГИСТИК»</h1>
|
<h1>О компании ООО «ИНТ ФИН ЛОГИСТИК»</h1>
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<p><strong>Директор:</strong> Оганнисян Ашот Рафикович</p>
|
<p><strong>Директор:</strong> Оганнисян Ашот Рафикович</p>
|
||||||
<p><strong>Юридический адрес:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
|
<p><strong>Юридический адрес:</strong><br>АРМЕНИЯ, 2301, КОТАЙКСКАЯ ОБЛАСТЬ, РАЗДАН, ХАЧАТРЯНА ул, 31, 4</p>
|
||||||
<p><strong>Офис в Армении:</strong><br>0033, Ереван, улица Братьев Орбели, 47</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>ИНН (РФ): 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>Банк: АО "Райффайзенбанк"<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@dexarmarket.ru<br>Сайт: www.dexarmarket.ru</p>
|
<p><strong>Контактная информация:</strong><br>Телефон (Россия): +7 (926) 459-31-57<br>Телефон (Армения): +374 94 86 18 16<br>Email: info@dexarmarket.ru<br>Сайт: www.dexarmarket.ru</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="legal-page">
|
<div class="legal-page">
|
||||||
<div class="legal-container">
|
<div class="legal-container">
|
||||||
<h1>Contacts</h1>
|
<h1>Contacts</h1>
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
<h2>Office Addresses</h2>
|
<h2>Office Addresses</h2>
|
||||||
<p><strong>Office in Armenia:</strong> 0033, Yerevan, Orbeli Brothers Street, 47</p>
|
<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>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="legal-page">
|
<div class="legal-page">
|
||||||
<div class="legal-container">
|
<div class="legal-container">
|
||||||
<h1>Կապ</h1>
|
<h1>Կապ</h1>
|
||||||
|
|
||||||
@@ -35,8 +35,7 @@
|
|||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
<h2>Գրասենյակների հասցեներ</h2>
|
<h2>Գրասենյակների հասցեներ</h2>
|
||||||
<p><strong>Գրասենյակ Հայաստանում՝</strong> 0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</p>
|
<p><strong>Գրասենյակ Հայաստանում՝</strong> 0033, Երևան, Եղբայրներ Օրբելի փողոց, 47</p>
|
||||||
<p><strong>Գրասենյակ Ռուսաստանում՝</strong> 121059, Մոսկվա, Տարաս Շևչենկոի փողոց, 3կ2</p>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
<h2>Տեխնիկական աջակցություն</h2>
|
<h2>Տեխնիկական աջակցություն</h2>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="legal-page">
|
<div class="legal-page">
|
||||||
<div class="legal-container">
|
<div class="legal-container">
|
||||||
<h1>Контакты</h1>
|
<h1>Контакты</h1>
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
<h2>Адреса офисов</h2>
|
<h2>Адреса офисов</h2>
|
||||||
<p><strong>Офис в Армении:</strong> 0033, Ереван, улица Братьев Орбели, 47</p>
|
<p><strong>Офис в Армении:</strong> 0033, Ереван, улица Братьев Орбели, 47</p>
|
||||||
<p><strong>Офис в России:</strong> 121059, Москва, наб. Тараса Шевченко, 3к2</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="legal-section">
|
<section class="legal-section">
|
||||||
|
|||||||
@@ -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-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
$dx-dark: #1e3c38;
|
$dx-dark: #1e3c38;
|
||||||
$dx-primary: #497671;
|
$dx-primary: #497671;
|
||||||
@@ -50,7 +52,7 @@ $dx-card-bg: #f5f3f9;
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: darken($dx-primary, 8%);
|
background: color.adjust($dx-primary, $lightness: -8%);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,7 +283,7 @@ $dx-card-bg: #f5f3f9;
|
|||||||
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
|
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: darken($dx-primary, 8%);
|
background: color.adjust($dx-primary, $lightness: -8%);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 16px rgba(73, 118, 113, 0.3);
|
box-shadow: 0 6px 16px rgba(73, 118, 113, 0.3);
|
||||||
}
|
}
|
||||||
@@ -492,7 +494,7 @@ $dx-card-bg: #f5f3f9;
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: darken($dx-primary, 8%);
|
background: color.adjust($dx-primary, $lightness: -8%);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
@if (items().length > 0) {
|
@if (items().length > 0) {
|
||||||
<div class="items-grid">
|
<div class="items-grid">
|
||||||
@for (item of items(); track trackByItemId($index, item)) {
|
@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">
|
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
||||||
<div class="item-image">
|
<div class="item-image">
|
||||||
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
|
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
|
||||||
@@ -105,19 +105,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</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 }}
|
{{ 'search.addToCart' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (loading() && items().length > 0) {
|
@if (loading() && items().length > 0) {
|
||||||
<div class="loading-more">
|
@for (i of skeletonSlots; track i) {
|
||||||
<div class="spinner"></div>
|
<div class="item-card skeleton-card">
|
||||||
<p>{{ 'search.loadingMore' | translate }}</p>
|
<div class="item-link">
|
||||||
</div>
|
<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) {
|
@if (!hasMore() && items().length > 0) {
|
||||||
<div class="no-more">
|
<div class="no-more">
|
||||||
|
|||||||
@@ -344,6 +344,59 @@
|
|||||||
text-align: center;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.search-header h1 {
|
.search-header h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DecimalPipe } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { ApiService, CartService } from '../../services';
|
import { ApiService, CartService } from '../../services';
|
||||||
|
import { PrefetchService } from '../../services/prefetch.service';
|
||||||
import { Item } from '../../models';
|
import { Item } from '../../models';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||||
@@ -11,6 +12,7 @@ import { LanguageService } from '../../services/language.service';
|
|||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../i18n/translate.service';
|
import { TranslateService } from '../../i18n/translate.service';
|
||||||
|
import { SEARCH_DEBOUNCE_MS, ITEMS_PER_PAGE, SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS } from '../../config/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-search',
|
selector: 'app-search',
|
||||||
@@ -28,7 +30,7 @@ export class SearchComponent implements OnDestroy {
|
|||||||
totalResults = signal<number>(0);
|
totalResults = signal<number>(0);
|
||||||
|
|
||||||
private skip = 0;
|
private skip = 0;
|
||||||
private readonly count = 50;
|
private readonly count = ITEMS_PER_PAGE;
|
||||||
private isLoadingMore = false;
|
private isLoadingMore = false;
|
||||||
private searchSubject = new Subject<string>();
|
private searchSubject = new Subject<string>();
|
||||||
private searchSubscription: Subscription;
|
private searchSubscription: Subscription;
|
||||||
@@ -36,11 +38,12 @@ export class SearchComponent implements OnDestroy {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private cartService: CartService
|
private cartService: CartService,
|
||||||
|
private prefetchService: PrefetchService
|
||||||
) {
|
) {
|
||||||
this.searchSubscription = this.searchSubject
|
this.searchSubscription = this.searchSubject
|
||||||
.pipe(
|
.pipe(
|
||||||
debounceTime(300),
|
debounceTime(SEARCH_DEBOUNCE_MS),
|
||||||
distinctUntilChanged()
|
distinctUntilChanged()
|
||||||
)
|
)
|
||||||
.subscribe(query => {
|
.subscribe(query => {
|
||||||
@@ -64,7 +67,7 @@ export class SearchComponent implements OnDestroy {
|
|||||||
performSearch(query: string): void {
|
performSearch(query: string): void {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
this.items.set([]);
|
this.items.set([]);
|
||||||
this.hasMore.set(true);
|
this.hasMore.set(false);
|
||||||
this.totalResults.set(0);
|
this.totalResults.set(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -120,12 +123,12 @@ export class SearchComponent implements OnDestroy {
|
|||||||
|
|
||||||
this.scrollTimeout = setTimeout(() => {
|
this.scrollTimeout = setTimeout(() => {
|
||||||
const scrollPosition = window.innerHeight + window.scrollY;
|
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()) {
|
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore()) {
|
||||||
this.loadResults();
|
this.loadResults();
|
||||||
}
|
}
|
||||||
}, 100);
|
}, SCROLL_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
addToCart(itemID: number, event: Event): void {
|
addToCart(itemID: number, event: Event): void {
|
||||||
@@ -134,6 +137,11 @@ export class SearchComponent implements OnDestroy {
|
|||||||
this.cartService.addItem(itemID);
|
this.cartService.addItem(itemID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onItemHover(itemID: number): void {
|
||||||
|
this.prefetchService.prefetchItem(itemID);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly skeletonSlots = Array.from({ length: 8 });
|
||||||
readonly getDiscountedPrice = getDiscountedPrice;
|
readonly getDiscountedPrice = getDiscountedPrice;
|
||||||
readonly getMainImage = getMainImage;
|
readonly getMainImage = getMainImage;
|
||||||
readonly trackByItemId = trackByItemId;
|
readonly trackByItemId = trackByItemId;
|
||||||
|
|||||||
@@ -7,19 +7,30 @@ import { LanguageService } from '../services/language.service';
|
|||||||
})
|
})
|
||||||
export class LangRoutePipe implements PipeTransform {
|
export class LangRoutePipe implements PipeTransform {
|
||||||
private langService = inject(LanguageService);
|
private langService = inject(LanguageService);
|
||||||
|
private lastLang = '';
|
||||||
|
private lastInput: unknown = null;
|
||||||
|
private lastResult: string | (string | number)[] = '';
|
||||||
|
|
||||||
transform(value: string | (string | number)[]): string | (string | number)[] {
|
transform(value: string | (string | number)[]): string | (string | number)[] {
|
||||||
const lang = this.langService.currentLanguage();
|
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') {
|
if (typeof value === 'string') {
|
||||||
return value === '/' ? `/${lang}` : `/${lang}${value}`;
|
this.lastResult = value === '/' ? `/${lang}` : `/${lang}${value}`;
|
||||||
}
|
} else if (Array.isArray(value) && value.length > 0) {
|
||||||
|
|
||||||
if (Array.isArray(value) && value.length > 0) {
|
|
||||||
const [first, ...rest] = value;
|
const [first, ...rest] = value;
|
||||||
return [`/${lang}${first}`, ...rest];
|
this.lastResult = [`/${lang}${first}`, ...rest];
|
||||||
|
} else {
|
||||||
|
this.lastResult = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return this.lastResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, timer } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map, retry } from 'rxjs/operators';
|
||||||
import { Category, Item, Subcategory } from '../models';
|
import { Category, Item, Subcategory } from '../models';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@@ -11,6 +11,11 @@ import { environment } from '../../environments/environment';
|
|||||||
export class ApiService {
|
export class ApiService {
|
||||||
private readonly baseUrl = environment.apiUrl;
|
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) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,7 +173,7 @@ export class ApiService {
|
|||||||
|
|
||||||
getCategories(): Observable<Category[]> {
|
getCategories(): Observable<Category[]> {
|
||||||
return this.http.get<any[]>(`${this.baseUrl}/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[]> {
|
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
|
||||||
@@ -176,12 +181,12 @@ export class ApiService {
|
|||||||
.set('count', count.toString())
|
.set('count', count.toString())
|
||||||
.set('skip', skip.toString());
|
.set('skip', skip.toString());
|
||||||
return this.http.get<any[]>(`${this.baseUrl}/category/${categoryID}`, { params })
|
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> {
|
getItem(itemID: number): Observable<Item> {
|
||||||
return this.http.get<any>(`${this.baseUrl}/item/${itemID}`)
|
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 }> {
|
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());
|
.set('skip', skip.toString());
|
||||||
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
|
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
|
||||||
.pipe(
|
.pipe(
|
||||||
|
retry(this.retryConfig),
|
||||||
map(response => ({
|
map(response => ({
|
||||||
items: this.normalizeItems(response?.items || []),
|
items: this.normalizeItems(response?.items || []),
|
||||||
total: response?.total || 0
|
total: response?.total || 0
|
||||||
@@ -298,6 +304,6 @@ export class ApiService {
|
|||||||
params = params.set('category', categoryID.toString());
|
params = params.set('category', categoryID.toString());
|
||||||
}
|
}
|
||||||
return this.http.get<any[]>(`${this.baseUrl}/randomitems`, { params })
|
return this.http.get<any[]>(`${this.baseUrl}/randomitems`, { params })
|
||||||
.pipe(map(items => this.normalizeItems(items)));
|
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export class CartService {
|
|||||||
private cartItems = signal<CartItem[]>([]);
|
private cartItems = signal<CartItem[]>([]);
|
||||||
private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp;
|
private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp;
|
||||||
private addingItems = new Set<number>();
|
private addingItems = new Set<number>();
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
items = this.cartItems.asReadonly();
|
items = this.cartItems.asReadonly();
|
||||||
itemCount = computed(() => {
|
itemCount = computed(() => {
|
||||||
@@ -31,10 +32,12 @@ export class CartService {
|
|||||||
constructor(private apiService: ApiService) {
|
constructor(private apiService: ApiService) {
|
||||||
this.loadCart();
|
this.loadCart();
|
||||||
|
|
||||||
// Auto-save whenever cart changes
|
// Auto-save whenever cart changes (skip the initial empty state)
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const items = this.cartItems();
|
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
|
// No data in CloudStorage, try localStorage
|
||||||
this.loadFromLocalStorage();
|
this.loadFromLocalStorage();
|
||||||
}
|
}
|
||||||
|
this.initialized = true;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.loadFromLocalStorage();
|
this.loadFromLocalStorage();
|
||||||
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export class LanguageService {
|
|||||||
|
|
||||||
languages: Language[] = [
|
languages: Language[] = [
|
||||||
{ code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true },
|
{ code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true },
|
||||||
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: true },
|
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: false },
|
||||||
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: true }
|
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
currencies: Currency[] = [
|
currencies: Currency[] = [
|
||||||
|
|||||||
15
src/app/services/prefetch.service.ts
Normal file
15
src/app/services/prefetch.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { Meta, Title } from '@angular/platform-browser';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { Item } from '../models';
|
import { Item } from '../models';
|
||||||
@@ -10,6 +10,7 @@ import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
|
|||||||
export class SeoService {
|
export class SeoService {
|
||||||
private meta = inject(Meta);
|
private meta = inject(Meta);
|
||||||
private title = inject(Title);
|
private title = inject(Title);
|
||||||
|
private doc = inject(DOCUMENT);
|
||||||
|
|
||||||
private readonly siteUrl = `https://${environment.domain}`;
|
private readonly siteUrl = `https://${environment.domain}`;
|
||||||
private readonly siteName = environment.brandFullName;
|
private readonly siteName = environment.brandFullName;
|
||||||
@@ -25,6 +26,7 @@ export class SeoService {
|
|||||||
const titleText = `${item.name} — ${this.siteName}`;
|
const titleText = `${item.name} — ${this.siteName}`;
|
||||||
|
|
||||||
this.title.setTitle(titleText);
|
this.title.setTitle(titleText);
|
||||||
|
this.setCanonical(itemUrl);
|
||||||
|
|
||||||
this.setOrUpdate([
|
this.setOrUpdate([
|
||||||
// Open Graph
|
// Open Graph
|
||||||
@@ -81,6 +83,7 @@ export class SeoService {
|
|||||||
// Remove product-specific tags
|
// Remove product-specific tags
|
||||||
this.meta.removeTag("property='product:price:amount'");
|
this.meta.removeTag("property='product:price:amount'");
|
||||||
this.meta.removeTag("property='product:price:currency'");
|
this.meta.removeTag("property='product:price:currency'");
|
||||||
|
this.removeCanonical();
|
||||||
}
|
}
|
||||||
|
|
||||||
private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void {
|
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 || '';
|
if (!text || text.length <= maxLength) return text || '';
|
||||||
return text.substring(0, maxLength - 1) + '…';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
start
4
start
@@ -1,2 +1,2 @@
|
|||||||
pm2 start "ng serve --configuration=novo --host 127.0.0.1 --port 4000" --name novo-market
|
pm2 start "ng serve --configuration=novo --host 127.0.0.1 --port 4010" --name novo-market
|
||||||
pm2 start "ng serve --host 127.0.0.1 --port 3000" --name dexar-market
|
pm2 start "ng serve --host 127.0.0.1 --port 4001" --name dexar-market
|
||||||
|
|||||||
Reference in New Issue
Block a user