This commit is contained in:
sdarbinyan
2026-03-06 18:40:58 +04:00
parent c3e4e695eb
commit 0b3b2ee463
25 changed files with 253 additions and 165 deletions

View File

@@ -175,26 +175,6 @@
}, },
"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"
]
}
} }
} }
} }

View File

@@ -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;

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 - Интернет-магазин", "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",

View File

@@ -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",

View File

@@ -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>

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: [` 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 {

View File

@@ -29,8 +29,8 @@
<div class="novo-right"> <div class="novo-right">
<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>
@@ -40,7 +40,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>
@@ -112,7 +112,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>

View File

@@ -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"

View File

@@ -31,6 +31,15 @@ export class LanguageSelectorComponent {
this.dropdownOpen = false; this.dropdownOpen = 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)) {

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 = 20;
// 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 { 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);
} }
} }

View File

@@ -12,6 +12,7 @@ import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/ite
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',
@@ -50,7 +51,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>;
@@ -196,14 +197,14 @@ export class CartComponent implements OnDestroy {
if (this.closeTimeout) clearTimeout(this.closeTimeout); 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.stopPolling(); this.stopPolling();
this.pollingSubscription = interval(5000) // every 5 seconds 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(() => {
@@ -230,7 +231,7 @@ export class CartComponent implements OnDestroy {
if (this.closeTimeout) clearTimeout(this.closeTimeout); 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) => {
@@ -239,7 +240,7 @@ export class CartComponent implements OnDestroy {
if (this.closeTimeout) clearTimeout(this.closeTimeout); if (this.closeTimeout) clearTimeout(this.closeTimeout);
this.closeTimeout = setTimeout(() => { this.closeTimeout = setTimeout(() => {
this.closePaymentPopup(); this.closePaymentPopup();
}, 3000); }, PAYMENT_TIMEOUT_CLOSE_MS);
} }
}); });
} }
@@ -255,7 +256,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);
}); });

View File

@@ -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]="item.name" loading="lazy" decoding="async" /> <img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" />
@@ -45,7 +45,7 @@
</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>

View File

@@ -2,11 +2,13 @@ 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 } from '../../utils/item.utils'; import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
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',
@@ -23,7 +25,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
hasMore = signal(true); hasMore = signal(true);
private skip = 0; private skip = 0;
private readonly count = 20; 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>;
@@ -31,7 +33,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 {
@@ -90,12 +93,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 - 1200; 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 {
@@ -104,6 +107,10 @@ 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 skeletonSlots = Array.from({ length: 8 });
readonly getDiscountedPrice = getDiscountedPrice; readonly getDiscountedPrice = getDiscountedPrice;
readonly getMainImage = getMainImage; readonly getMainImage = getMainImage;

View File

@@ -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()) {

View File

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

View File

@@ -21,6 +21,7 @@ export class HomeComponent implements OnInit, OnDestroy {
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(() => {

View File

@@ -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]="item.name" loading="lazy" decoding="async" width="300" height="300" /> <img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" width="300" height="300" />
@@ -94,19 +94,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">

View File

@@ -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;

View File

@@ -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';
@@ -10,6 +11,7 @@ import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/ite
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',
@@ -27,7 +29,7 @@ export class SearchComponent implements OnDestroy {
totalResults = signal<number>(0); totalResults = signal<number>(0);
private skip = 0; private skip = 0;
private readonly count = 20; 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;
@@ -35,11 +37,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 => {
@@ -119,12 +122,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 {
@@ -133,6 +136,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;

View File

@@ -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 } from '../models'; import { Category, Item } 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) {}
private normalizeItem(item: Item): Item { private normalizeItem(item: Item): Item {
@@ -32,7 +37,7 @@ export class ApiService {
} }
getCategories(): Observable<Category[]> { getCategories(): Observable<Category[]> {
return this.http.get<Category[]>(`${this.baseUrl}/category`); return this.http.get<Category[]>(`${this.baseUrl}/category`).pipe(retry(this.retryConfig));
} }
getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> { getCategoryItems(categoryID: number, count: number = 50, skip: number = 0): Observable<Item[]> {
@@ -40,12 +45,12 @@ export class ApiService {
.set('count', count.toString()) .set('count', count.toString())
.set('skip', skip.toString()); .set('skip', skip.toString());
return this.http.get<Item[]>(`${this.baseUrl}/category/${categoryID}`, { params }) return this.http.get<Item[]>(`${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<Item>(`${this.baseUrl}/item/${itemID}`) return this.http.get<Item>(`${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 }> {
@@ -55,6 +60,7 @@ export class ApiService {
.set('skip', skip.toString()); .set('skip', skip.toString());
return this.http.get<{ items: Item[], total: number, count: number, skip: number }>(`${this.baseUrl}/searchitems`, { params }) return this.http.get<{ items: Item[], total: number, count: number, skip: number }>(`${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
@@ -162,6 +168,6 @@ export class ApiService {
params = params.set('category', categoryID.toString()); params = params.set('category', categoryID.toString());
} }
return this.http.get<Item[]>(`${this.baseUrl}/randomitems`, { params }) return this.http.get<Item[]>(`${this.baseUrl}/randomitems`, { params })
.pipe(map(items => this.normalizeItems(items))); .pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
} }
} }

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