changes
This commit is contained in:
@@ -12,6 +12,7 @@ import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/ite
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS, PAYMENT_ERROR_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cart',
|
||||
@@ -50,7 +51,7 @@ export class CartComponent implements OnDestroy {
|
||||
emailSubmitting = signal<boolean>(false);
|
||||
paidItems: CartItem[] = [];
|
||||
|
||||
maxChecks = 36; // 36 checks * 5 seconds = 180 seconds (3 minutes)
|
||||
maxChecks = PAYMENT_MAX_CHECKS;
|
||||
private pollingSubscription?: Subscription;
|
||||
private closeTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -196,14 +197,14 @@ export class CartComponent implements OnDestroy {
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, 4000);
|
||||
}, PAYMENT_ERROR_CLOSE_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startPolling(): void {
|
||||
this.stopPolling();
|
||||
this.pollingSubscription = interval(5000) // every 5 seconds
|
||||
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
|
||||
.pipe(
|
||||
take(this.maxChecks), // maximum 36 checks (3 minutes)
|
||||
switchMap(() => {
|
||||
@@ -230,7 +231,7 @@ export class CartComponent implements OnDestroy {
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, 3000);
|
||||
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -239,7 +240,7 @@ export class CartComponent implements OnDestroy {
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, 3000);
|
||||
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -255,7 +256,7 @@ export class CartComponent implements OnDestroy {
|
||||
if (url) {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.linkCopied.set(true);
|
||||
setTimeout(() => this.linkCopied.set(false), 2000);
|
||||
setTimeout(() => this.linkCopied.set(false), LINK_COPIED_DURATION_MS);
|
||||
}).catch(err => {
|
||||
console.error(this.i18n.t('cart.copyError'), err);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@if (!error()) {
|
||||
<div class="items-grid">
|
||||
@for (item of items(); track trackByItemId($index, item)) {
|
||||
<div class="item-card">
|
||||
<div class="item-card" (mouseenter)="onItemHover(item.itemID)">
|
||||
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
||||
<div class="item-image">
|
||||
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" />
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)">
|
||||
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)" [attr.aria-label]="('category.addToCart' | translate) + ': ' + item.name">
|
||||
{{ 'category.addToCart' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStra
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ApiService, CartService } from '../../services';
|
||||
import { PrefetchService } from '../../services/prefetch.service';
|
||||
import { Item } from '../../models';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS, ITEMS_PER_PAGE } from '../../config/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category',
|
||||
@@ -23,7 +25,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
hasMore = signal(true);
|
||||
|
||||
private skip = 0;
|
||||
private readonly count = 20;
|
||||
private readonly count = ITEMS_PER_PAGE;
|
||||
private isLoadingMore = false;
|
||||
private routeSubscription?: Subscription;
|
||||
private scrollTimeout?: ReturnType<typeof setTimeout>;
|
||||
@@ -31,7 +33,8 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private apiService: ApiService,
|
||||
private cartService: CartService
|
||||
private cartService: CartService,
|
||||
private prefetchService: PrefetchService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -90,12 +93,12 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
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) {
|
||||
this.loadItems();
|
||||
}
|
||||
}, 100);
|
||||
}, SCROLL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
addToCart(itemID: number, event: Event): void {
|
||||
@@ -104,6 +107,10 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
this.cartService.addItem(itemID);
|
||||
}
|
||||
|
||||
onItemHover(itemID: number): void {
|
||||
this.prefetchService.prefetchItem(itemID);
|
||||
}
|
||||
|
||||
readonly skeletonSlots = Array.from({ length: 8 });
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getMainImage = getMainImage;
|
||||
|
||||
@@ -19,10 +19,23 @@
|
||||
<app-items-carousel />
|
||||
|
||||
@if (loading()) {
|
||||
<div class="novo-loading">
|
||||
<div class="novo-spinner"></div>
|
||||
<p>{{ 'home.loading' | translate }}</p>
|
||||
</div>
|
||||
<section class="novo-categories">
|
||||
<div class="novo-section-header">
|
||||
<div class="skeleton-line" style="height: 32px; width: 200px; margin: 0 auto 12px;"></div>
|
||||
<div class="skeleton-line" style="height: 18px; width: 300px; margin: 0 auto;"></div>
|
||||
</div>
|
||||
<div class="novo-categories-grid">
|
||||
@for (i of skeletonSlots; track i) {
|
||||
<div class="novo-category-card skeleton-card">
|
||||
<div class="novo-category-image skeleton-image"></div>
|
||||
<div class="novo-category-info">
|
||||
<div class="skeleton-line" style="height: 18px; width: 70%;"></div>
|
||||
<div class="skeleton-line" style="height: 18px; width: 20px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
@@ -101,10 +114,20 @@
|
||||
<app-items-carousel />
|
||||
|
||||
@if (loading()) {
|
||||
<div class="dexar-loading">
|
||||
<div class="dexar-spinner"></div>
|
||||
<p>{{ 'home.loadingDexar' | translate }}</p>
|
||||
</div>
|
||||
<section class="dexar-categories">
|
||||
<div class="skeleton-line" style="height: 36px; width: 220px; margin-bottom: 40px;"></div>
|
||||
<div class="dexar-categories-grid">
|
||||
@for (i of skeletonSlots; track i) {
|
||||
<div class="dexar-category-card skeleton-card">
|
||||
<div class="dexar-category-image skeleton-image"></div>
|
||||
<div class="dexar-category-info">
|
||||
<div class="skeleton-line" style="height: 16px; width: 75%;"></div>
|
||||
<div class="skeleton-line" style="height: 12px; width: 40%; margin-top: 4px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
|
||||
@@ -896,3 +896,26 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
// Skeleton loading cards
|
||||
.skeleton-card {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeleton-image {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
wideCategories = signal<Set<number>>(new Set());
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
readonly skeletonSlots = Array.from({ length: 6 });
|
||||
|
||||
// Memoized computed values for performance
|
||||
topLevelCategories = computed(() => {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
@if (items().length > 0) {
|
||||
<div class="items-grid">
|
||||
@for (item of items(); track trackByItemId($index, item)) {
|
||||
<div class="item-card">
|
||||
<div class="item-card" (mouseenter)="onItemHover(item.itemID)">
|
||||
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
||||
<div class="item-image">
|
||||
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" width="300" height="300" />
|
||||
@@ -94,19 +94,29 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)">
|
||||
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)" [attr.aria-label]="('search.addToCart' | translate) + ': ' + item.name">
|
||||
{{ 'search.addToCart' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading() && items().length > 0) {
|
||||
<div class="loading-more">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ 'search.loadingMore' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (loading() && items().length > 0) {
|
||||
@for (i of skeletonSlots; track i) {
|
||||
<div class="item-card skeleton-card">
|
||||
<div class="item-link">
|
||||
<div class="item-image skeleton-image"></div>
|
||||
<div class="item-details">
|
||||
<div class="skeleton-line skeleton-title"></div>
|
||||
<div class="skeleton-line skeleton-rating"></div>
|
||||
<div class="skeleton-line skeleton-price"></div>
|
||||
<div class="skeleton-line skeleton-stock"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skeleton-btn"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!hasMore() && items().length > 0) {
|
||||
<div class="no-more">
|
||||
|
||||
@@ -344,6 +344,59 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Skeleton loading cards
|
||||
.skeleton-card {
|
||||
pointer-events: none;
|
||||
|
||||
.skeleton-image {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 16px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.skeleton-rating {
|
||||
height: 12px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.skeleton-price {
|
||||
height: 18px;
|
||||
width: 40%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.skeleton-stock {
|
||||
height: 6px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.skeleton-btn {
|
||||
height: 42px;
|
||||
background: linear-gradient(90deg, #5a8a85 25%, #497671 50%, #5a8a85 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 0 0 13px 13px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-header h1 {
|
||||
font-size: 1.5rem;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DecimalPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ApiService, CartService } from '../../services';
|
||||
import { PrefetchService } from '../../services/prefetch.service';
|
||||
import { Item } from '../../models';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
@@ -10,6 +11,7 @@ import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/ite
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
import { SEARCH_DEBOUNCE_MS, ITEMS_PER_PAGE, SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS } from '../../config/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-search',
|
||||
@@ -27,7 +29,7 @@ export class SearchComponent implements OnDestroy {
|
||||
totalResults = signal<number>(0);
|
||||
|
||||
private skip = 0;
|
||||
private readonly count = 20;
|
||||
private readonly count = ITEMS_PER_PAGE;
|
||||
private isLoadingMore = false;
|
||||
private searchSubject = new Subject<string>();
|
||||
private searchSubscription: Subscription;
|
||||
@@ -35,11 +37,12 @@ export class SearchComponent implements OnDestroy {
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private cartService: CartService
|
||||
private cartService: CartService,
|
||||
private prefetchService: PrefetchService
|
||||
) {
|
||||
this.searchSubscription = this.searchSubject
|
||||
.pipe(
|
||||
debounceTime(300),
|
||||
debounceTime(SEARCH_DEBOUNCE_MS),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe(query => {
|
||||
@@ -119,12 +122,12 @@ export class SearchComponent implements OnDestroy {
|
||||
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const bottomPosition = document.documentElement.scrollHeight - 500;
|
||||
const bottomPosition = document.documentElement.scrollHeight - SCROLL_THRESHOLD_PX;
|
||||
|
||||
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore()) {
|
||||
this.loadResults();
|
||||
}
|
||||
}, 100);
|
||||
}, SCROLL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
addToCart(itemID: number, event: Event): void {
|
||||
@@ -133,6 +136,11 @@ export class SearchComponent implements OnDestroy {
|
||||
this.cartService.addItem(itemID);
|
||||
}
|
||||
|
||||
onItemHover(itemID: number): void {
|
||||
this.prefetchService.prefetchItem(itemID);
|
||||
}
|
||||
|
||||
readonly skeletonSlots = Array.from({ length: 8 });
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getMainImage = getMainImage;
|
||||
readonly trackByItemId = trackByItemId;
|
||||
|
||||
Reference in New Issue
Block a user