changes
This commit is contained in:
20
angular.json
20
angular.json
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
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 = 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;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user