feat(front-end): faster load
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 19s

This commit is contained in:
2026-03-14 19:28:30 +01:00
parent 996e95f93c
commit df63937406
5 changed files with 238 additions and 45 deletions

View File

@@ -1,22 +1,89 @@
import { Injectable } from '@angular/core'; import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import {
Injectable,
PLATFORM_ID,
TransferState,
inject,
makeStateKey,
} from '@angular/core';
import { TranslateLoader, TranslationObject } from '@ngx-translate/core'; import { TranslateLoader, TranslationObject } from '@ngx-translate/core';
import { Observable, of } from 'rxjs'; import { from, Observable } from 'rxjs';
import de from '../../../assets/i18n/de.json';
import en from '../../../assets/i18n/en.json';
import fr from '../../../assets/i18n/fr.json';
import it from '../../../assets/i18n/it.json';
const TRANSLATIONS: Record<string, TranslationObject> = { type SupportedLang = 'it' | 'en' | 'de' | 'fr';
it: it as TranslationObject,
en: en as TranslationObject, const FALLBACK_LANG: SupportedLang = 'it';
de: de as TranslationObject, const translationCache = new Map<SupportedLang, Promise<TranslationObject>>();
fr: fr as TranslationObject,
const translationLoaders: Record<SupportedLang, () => Promise<TranslationObject>> = {
it: () =>
import('../../../assets/i18n/it.json').then(
(module) => module.default as TranslationObject,
),
en: () =>
import('../../../assets/i18n/en.json').then(
(module) => module.default as TranslationObject,
),
de: () =>
import('../../../assets/i18n/de.json').then(
(module) => module.default as TranslationObject,
),
fr: () =>
import('../../../assets/i18n/fr.json').then(
(module) => module.default as TranslationObject,
),
}; };
@Injectable() @Injectable()
export class StaticTranslateLoader implements TranslateLoader { export class StaticTranslateLoader implements TranslateLoader {
private readonly platformId = inject(PLATFORM_ID);
private readonly transferState = inject(TransferState);
getTranslation(lang: string): Observable<TranslationObject> { getTranslation(lang: string): Observable<TranslationObject> {
const normalized = String(lang || 'it').toLowerCase(); const normalized = this.normalizeLanguage(lang);
return of(TRANSLATIONS[normalized] ?? TRANSLATIONS['it']); return from(this.loadTranslation(normalized));
}
private normalizeLanguage(lang: string): SupportedLang {
const normalized = String(lang || FALLBACK_LANG).toLowerCase();
return normalized in translationLoaders
? (normalized as SupportedLang)
: FALLBACK_LANG;
}
private loadTranslation(lang: SupportedLang): Promise<TranslationObject> {
const transferStateKey =
makeStateKey<TranslationObject>(`i18n:${lang.toLowerCase()}`);
if (
isPlatformBrowser(this.platformId) &&
this.transferState.hasKey(transferStateKey)
) {
const transferred = this.transferState.get(transferStateKey, {});
this.transferState.remove(transferStateKey);
return Promise.resolve(transferred);
}
const cached = translationCache.get(lang);
if (cached) {
return cached;
}
const pending = translationLoaders[lang]()
.then((translation) => {
if (
isPlatformServer(this.platformId) &&
!this.transferState.hasKey(transferStateKey)
) {
this.transferState.set(transferStateKey, translation);
}
return translation;
})
.catch(() =>
lang === FALLBACK_LANG
? Promise.resolve({})
: this.loadTranslation(FALLBACK_LANG),
);
translationCache.set(lang, pending);
return pending;
} }
} }

View File

@@ -1,5 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; import {
afterNextRender,
Component,
DestroyRef,
computed,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
NavigationStart, NavigationStart,
@@ -58,16 +65,9 @@ export class NavbarComponent {
]; ];
constructor(public langService: LanguageService) { constructor(public langService: LanguageService) {
if (!this.shopService.cartLoaded()) { afterNextRender(() => {
this.shopService this.scheduleCartWarmup();
.loadCart() });
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
this.router.events this.router.events
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -96,6 +96,9 @@ export class NavbarComponent {
toggleCart(): void { toggleCart(): void {
this.closeMenu(); this.closeMenu();
this.isCartOpen.update((open) => !open); this.isCartOpen.update((open) => !open);
if (this.isCartOpen()) {
this.loadCartIfNeeded();
}
} }
closeCart(): void { closeCart(): void {
@@ -192,5 +195,44 @@ export class NavbarComponent {
.subscribe(); .subscribe();
} }
private scheduleCartWarmup(): void {
if (typeof window === 'undefined') {
this.loadCartIfNeeded();
return;
}
const warmup = () => this.loadCartIfNeeded();
const idleCallback = (
window as Window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions,
) => number;
}
).requestIdleCallback;
if (typeof idleCallback === 'function') {
idleCallback(() => warmup(), { timeout: 1500 });
return;
}
window.setTimeout(warmup, 300);
}
private loadCartIfNeeded(): void {
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
return;
}
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
protected readonly routes = routes; protected readonly routes = routes;
} }

View File

@@ -1,5 +1,6 @@
import { CommonModule, isPlatformBrowser } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { import {
afterNextRender,
Component, Component,
DestroyRef, DestroyRef,
Injector, Injector,
@@ -193,16 +194,9 @@ export class ProductDetailComponent {
); );
constructor() { constructor() {
if (!this.shopService.cartLoaded()) { afterNextRender(() => {
this.shopService this.scheduleCartWarmup();
.loadCart() });
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
combineLatest([ combineLatest([
toObservable(this.productSlug, { injector: this.injector }), toObservable(this.productSlug, { injector: this.injector }),
@@ -279,7 +273,46 @@ export class ProductDetailComponent {
this.shopService.resolveMediaUrl(image.hero) ?? this.shopService.resolveMediaUrl(image.hero) ??
this.shopService.resolveMediaUrl(image.card) ?? this.shopService.resolveMediaUrl(image.card) ??
this.shopService.resolveMediaUrl(image.thumb) this.shopService.resolveMediaUrl(image.thumb)
); );
}
private scheduleCartWarmup(): void {
if (typeof window === 'undefined') {
this.loadCartIfNeeded();
return;
}
const warmup = () => this.loadCartIfNeeded();
const idleCallback = (
window as Window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions,
) => number;
}
).requestIdleCallback;
if (typeof idleCallback === 'function') {
idleCallback(() => warmup(), { timeout: 1500 });
return;
}
window.setTimeout(warmup, 300);
}
private loadCartIfNeeded(): void {
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
return;
}
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
} }
selectImage(mediaAssetId: string): void { selectImage(mediaAssetId: string): void {

View File

@@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
afterNextRender,
Component, Component,
DestroyRef, DestroyRef,
Injector, Injector,
@@ -89,16 +90,9 @@ export class ShopPageComponent {
readonly cartHasItems = computed(() => this.cartItems().length > 0); readonly cartHasItems = computed(() => this.cartItems().length > 0);
constructor() { constructor() {
if (!this.shopService.cartLoaded()) { afterNextRender(() => {
this.shopService this.scheduleCartWarmup();
.loadCart() });
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
combineLatest([ combineLatest([
toObservable(this.categorySlug, { injector: this.injector }), toObservable(this.categorySlug, { injector: this.injector }),
@@ -150,6 +144,45 @@ export class ShopPageComponent {
}); });
} }
private scheduleCartWarmup(): void {
if (typeof window === 'undefined') {
this.loadCartIfNeeded();
return;
}
const warmup = () => this.loadCartIfNeeded();
const idleCallback = (
window as Window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions,
) => number;
}
).requestIdleCallback;
if (typeof idleCallback === 'function') {
idleCallback(() => warmup(), { timeout: 1500 });
return;
}
window.setTimeout(warmup, 300);
}
private loadCartIfNeeded(): void {
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
return;
}
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
productCartQuantity(productId: string): number { productCartQuantity(productId: string): number {
return this.shopService.quantityForProduct(productId); return this.shopService.quantityForProduct(productId);
} }

View File

@@ -11,14 +11,32 @@
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="assets/images/Fav-icon.png" /> <link rel="icon" type="image/png" href="assets/images/Fav-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
media="print"
onload="this.media='all'"
/> />
<noscript>
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</noscript>
<link <link
href="https://fonts.googleapis.com/icon?family=Material+Icons" href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet" rel="stylesheet"
media="print"
onload="this.media='all'"
/> />
<noscript>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
</noscript>
<meta name="msvalidate.01" content="5AF60C1471E1800B39DF4DBC3C709035" /> <meta name="msvalidate.01" content="5AF60C1471E1800B39DF4DBC3C709035" />
</head> </head>
<body> <body>