From df6393740647af73b91b3554b9ae4a20d19eadbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Sat, 14 Mar 2026 19:28:30 +0100 Subject: [PATCH] feat(front-end): faster load --- .../app/core/i18n/static-translate.loader.ts | 93 ++++++++++++++++--- .../src/app/core/layout/navbar.component.ts | 64 ++++++++++--- .../features/shop/product-detail.component.ts | 55 ++++++++--- .../app/features/shop/shop-page.component.ts | 53 +++++++++-- frontend/src/index.html | 18 ++++ 5 files changed, 238 insertions(+), 45 deletions(-) diff --git a/frontend/src/app/core/i18n/static-translate.loader.ts b/frontend/src/app/core/i18n/static-translate.loader.ts index 3821187..9ec2939 100644 --- a/frontend/src/app/core/i18n/static-translate.loader.ts +++ b/frontend/src/app/core/i18n/static-translate.loader.ts @@ -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 { Observable, of } 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'; +import { from, Observable } from 'rxjs'; -const TRANSLATIONS: Record = { - it: it as TranslationObject, - en: en as TranslationObject, - de: de as TranslationObject, - fr: fr as TranslationObject, +type SupportedLang = 'it' | 'en' | 'de' | 'fr'; + +const FALLBACK_LANG: SupportedLang = 'it'; +const translationCache = new Map>(); + +const translationLoaders: Record Promise> = { + 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() export class StaticTranslateLoader implements TranslateLoader { + private readonly platformId = inject(PLATFORM_ID); + private readonly transferState = inject(TransferState); + getTranslation(lang: string): Observable { - const normalized = String(lang || 'it').toLowerCase(); - return of(TRANSLATIONS[normalized] ?? TRANSLATIONS['it']); + const normalized = this.normalizeLanguage(lang); + 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 { + const transferStateKey = + makeStateKey(`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; } } diff --git a/frontend/src/app/core/layout/navbar.component.ts b/frontend/src/app/core/layout/navbar.component.ts index 2551af7..31571de 100644 --- a/frontend/src/app/core/layout/navbar.component.ts +++ b/frontend/src/app/core/layout/navbar.component.ts @@ -1,5 +1,12 @@ 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 { NavigationStart, @@ -58,16 +65,9 @@ export class NavbarComponent { ]; constructor(public langService: LanguageService) { - if (!this.shopService.cartLoaded()) { - this.shopService - .loadCart() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - error: () => { - this.shopService.cart.set(null); - }, - }); - } + afterNextRender(() => { + this.scheduleCartWarmup(); + }); this.router.events .pipe(takeUntilDestroyed(this.destroyRef)) @@ -96,6 +96,9 @@ export class NavbarComponent { toggleCart(): void { this.closeMenu(); this.isCartOpen.update((open) => !open); + if (this.isCartOpen()) { + this.loadCartIfNeeded(); + } } closeCart(): void { @@ -192,5 +195,44 @@ export class NavbarComponent { .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; } diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 09cc991..0ee935b 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -1,5 +1,6 @@ import { CommonModule, isPlatformBrowser } from '@angular/common'; import { + afterNextRender, Component, DestroyRef, Injector, @@ -193,16 +194,9 @@ export class ProductDetailComponent { ); constructor() { - if (!this.shopService.cartLoaded()) { - this.shopService - .loadCart() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - error: () => { - this.shopService.cart.set(null); - }, - }); - } + afterNextRender(() => { + this.scheduleCartWarmup(); + }); combineLatest([ toObservable(this.productSlug, { injector: this.injector }), @@ -279,7 +273,46 @@ export class ProductDetailComponent { this.shopService.resolveMediaUrl(image.hero) ?? this.shopService.resolveMediaUrl(image.card) ?? 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 { diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index ed192df..d8e4e32 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { + afterNextRender, Component, DestroyRef, Injector, @@ -89,16 +90,9 @@ export class ShopPageComponent { readonly cartHasItems = computed(() => this.cartItems().length > 0); constructor() { - if (!this.shopService.cartLoaded()) { - this.shopService - .loadCart() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - error: () => { - this.shopService.cart.set(null); - }, - }); - } + afterNextRender(() => { + this.scheduleCartWarmup(); + }); combineLatest([ 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 { return this.shopService.quantityForProduct(productId); } diff --git a/frontend/src/index.html b/frontend/src/index.html index 7ece83b..d2aabc3 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -11,14 +11,32 @@ + + + +