From 4bf0666fd16e3bc8b8d152d02b3dd9944f6e5b6f Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 24 Jul 2026 08:19:13 +0400 Subject: [PATCH] perf(app): lazy-load i18n translation packs, drop dead items-carousel component RC PERF-01 bundle audit follow-up on 61a5714. - i18n: ru/en/hy translation packs (346 KB raw combined) were all statically imported in TranslateService and shipped in the initial bundle regardless of the visitor's language. Now only 'ru' (platform default) is bundled eagerly; 'en'/'hy' are dynamic import()s. The language route guard (languageGuard) awaits preloadLanguage() before activating the route, so translations are always fully loaded before any component renders - no flash of untranslated/fallback content. - widget-host.service.ts: import UnknownWidgetComponent directly instead of via the widgets/ui barrel (index.ts re-exports 6 widgets). - Deleted src/app/components/items-carousel/* - confirmed dead (zero references anywhere, verified via knip and grep), the only consumer of primeng/primeicons in the app. Removed the now-unused `@import 'primeicons/primeicons.css'` from styles.scss (no primeicons CSS classes used elsewhere). primeng/primeicons remain listed in package.json/package-lock.json - npm CLI in this environment is blocked by an unrelated, pre-existing broken `barry-cache` devDependency (ETARGET on `npm install`/`npm uninstall`), so the lockfile could not be safely regenerated. Flagged, not fixed. Routes audit (app.routes.ts): all storefront/builder/backoffice feature routes already use loadComponent/loadChildren; nothing eagerly imported. No route changes needed. Lucide icons (icon-registry.ts): already named/tree-shakeable imports from @lucide/angular, not a full-library import. No change needed. Before/after (npm run build, production): - Initial bundle raw: 1.47 MB -> 1.12 MB (-350 KB / -24%) - Initial bundle transfer (est.): 263.59 kB -> 221.51 kB (-42 kB / -16%) - Budget overage: 769.22 kB over -> 416.84 kB over (still exceeds the 700 KB budget; project-editor-page-component (320 kB), catalog-container-component (126 kB), product-details-container (88 kB), cart-component (61 kB) lazy chunks unchanged - no safe mechanical split identified within scope, see PERF-01 report for detail). Verified: npx tsc --noEmit clean, npm run build green (warning only, no errors). Co-Authored-By: Claude Sonnet 5 --- .../items-carousel.component.html | 64 --- .../items-carousel.component.scss | 375 ------------------ .../items-carousel.component.ts | 113 ------ .../widget-host/widget-host.service.ts | 2 +- src/app/guards/language.guard.ts | 8 +- src/app/i18n/translate.service.ts | 35 +- src/styles.scss | 3 - 7 files changed, 37 insertions(+), 563 deletions(-) delete mode 100644 src/app/components/items-carousel/items-carousel.component.html delete mode 100644 src/app/components/items-carousel/items-carousel.component.scss delete mode 100644 src/app/components/items-carousel/items-carousel.component.ts diff --git a/src/app/components/items-carousel/items-carousel.component.html b/src/app/components/items-carousel/items-carousel.component.html deleted file mode 100644 index a0e09b6..0000000 --- a/src/app/components/items-carousel/items-carousel.component.html +++ /dev/null @@ -1,64 +0,0 @@ - diff --git a/src/app/components/items-carousel/items-carousel.component.scss b/src/app/components/items-carousel/items-carousel.component.scss deleted file mode 100644 index 0f86722..0000000 --- a/src/app/components/items-carousel/items-carousel.component.scss +++ /dev/null @@ -1,375 +0,0 @@ -.carousel-container { - width: 100%; - padding: 2rem 0; - max-width: 1400px; - margin: 0 auto; - font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - - ::ng-deep { - // PrimeNG carousel wrapper - .p-carousel { - .p-carousel-content { - display: flex; - flex-direction: row; - align-items: center; - gap: 1.5rem; - } - - // Navigation buttons - .p-carousel-prev, - .p-carousel-next { - width: 3rem; - height: 3rem; - border-radius: 50%; - background: white; - border: 2px solid #d3dad9; - color: #1e3c38; - transition: all 0.3s ease; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - align-self: center; - z-index: 10; - flex-shrink: 0; - - &:hover { - background: #f9fafb; - border-color: #d3dad9; - transform: scale(1.05); - } - - &:not(:disabled):hover { - background: var(--primary-color, #497671); - border-color: var(--primary-color, #497671); - color: white; - } - } - - // Items container - .p-carousel-items-container { - .p-carousel-items-content { - width: 100%; - - .p-carousel-item { - padding: 0 0.5rem !important; - box-sizing: border-box; - } - } - } - - // Add gap between items - .p-carousel-items-content .p-carousel-item-list { - display: flex; - } - - .p-carousel-item { - flex-shrink: 0; - } - - // Pagination dots - .p-carousel-indicator-list { - display: flex !important; - justify-content: center !important; - gap: 0.5rem !important; - padding: 1.5rem 0 !important; - margin: 0 !important; - list-style: none !important; - - .p-carousel-indicator { - display: inline-block !important; - - .p-carousel-indicator-button { - width: 12px !important; - height: 12px !important; - border-radius: 50% !important; - background-color: #d3dad9 !important; - border: 0 !important; - padding: 0 !important; - cursor: pointer !important; - transition: all 0.3s ease !important; - - &:hover { - background-color: #a1b4b5 !important; - transform: scale(1.2); - } - } - - &.p-carousel-indicator-active .p-carousel-indicator-button { - background-color: var(--primary-color, #497671) !important; - width: 32px !important; - border-radius: 6px !important; - } - } - } - } - } -} - -.carousel-loading, -.carousel-empty { - text-align: center; - padding: 3rem 1rem; - color: #697777; - - .spinner { - width: 40px; - height: 40px; - margin: 0 auto 1rem; - border: 4px solid #f3f3f3; - border-top: 4px solid var(--primary-color, #497671); - border-radius: 50%; - animation: spin 1s linear infinite; - } -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -// Item card styles -.item-card { - background: #ffffff; - border-radius: var(--radius-lg, 13px); - border: 1px solid #d3dad9; - overflow: hidden; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); - transition: box-shadow 0.3s ease, transform 0.3s ease; - position: relative; - height: 100%; - display: flex; - flex-direction: column; - width: calc(100% - 1rem) !important; - box-sizing: border-box; - margin: 0 auto; - - &:hover { - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12); - transform: translateY(-4px); - } -} - -.item-link { - text-decoration: none; - color: inherit; - display: flex; - flex-direction: column; - flex: 1; -} - -.item-image { - position: relative; - width: 100%; - height: 170px; - overflow: hidden; - background: #f5f3f9; - - img { - width: 100%; - height: 100%; - object-fit: contain; - padding: 12px; - transition: transform 0.4s ease; - box-sizing: border-box; - } - - .item-card:hover & img { - transform: scale(1.06); - } -} - -.discount-badge { - position: absolute; - top: 10px; - left: 10px; - background: #ef4444; - color: white; - padding: 4px 10px; - border-radius: 20px; - font-family: "DM Sans", sans-serif; - font-size: var(--font-size-xs, 0.75rem); - font-weight: var(--font-weight-bold, 700); - letter-spacing: 0.02em; - z-index: 2; - line-height: 1.3; -} - -.item-details { - padding: 12px 14px 14px; - display: flex; - flex-direction: column; - gap: 6px; - flex: 1; -} - -.item-name { - font-family: "DM Sans", sans-serif; - font-size: var(--font-size-sm, 0.8125rem); - font-weight: var(--font-weight-semibold, 600); - margin: 0; - line-height: 1.35; - min-height: 2.2em; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - color: #1e3c38; - transition: color 0.2s; - - .item-card:hover & { - color: #497671; - } -} - -.item-rating { - display: flex; - align-items: center; - gap: 4px; - font-family: "DM Sans", sans-serif; - font-size: var(--font-size-xs, 0.75rem); - - svg { - flex-shrink: 0; - } - - .rating-value { - font-weight: var(--font-weight-semibold, 600); - color: #1e3c38; - } - - .rating-count { - color: #697777; - } -} - -.item-price-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-top: auto; - padding-top: 4px; -} - -.item-price { - display: flex; - align-items: baseline; - gap: 6px; - flex-wrap: wrap; - flex: 1; - font-family: "DM Sans", sans-serif; - - .current-price, - .discounted-price { - font-size: var(--font-size-md, 0.9375rem); - font-weight: var(--font-weight-bold, 700); - color: #1e3c38; - } - - .discounted-price { - color: #ef4444; - } - - .original-price { - font-size: var(--font-size-xs, 0.75rem); - color: #a1b4b5; - text-decoration: line-through; - } -} - -.cart-icon-btn { - width: 36px; - height: 36px; - padding: 0; - background: #497671; - color: white; - border: none; - border-radius: 10px; - cursor: pointer; - transition: background 0.2s ease, transform 0.15s ease; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - svg { - display: block; - width: 18px; - height: 18px; - } - - &:hover { - background: #3d635f; - transform: scale(1.08); - } - - &:active { - transform: scale(0.95); - } -} - -// alt theme styles -.alt-theme { - ::ng-deep { - .p-carousel { - .p-carousel-prev, - .p-carousel-next { - &:not(:disabled):hover { - background: var(--primary-color, #497671); - border-color: var(--primary-color, #497671); - } - } - - .p-carousel-indicators { - .p-carousel-indicator.p-highlight button { - background: var(--primary-color, #497671); - } - } - } - } - - .item-card { - border: 1px solid #e5e5e5; - } - - .cart-icon-btn { - background: var(--primary-color, #497671); - - &:hover { - background: var(--primary-hover, #3d635f); - } - } -} - -// Responsive styles -@media (max-width: 968px) { - .carousel-container { - padding: 0 1rem; - } - - .item-image { - height: 160px; - } -} - -@media (max-width: 640px) { - .item-image { - height: 150px; - } - - .item-details { - padding: 10px 12px 12px; - } - - .item-name { - font-size: var(--font-size-sm, 0.8125rem); - } - - .item-price { - .current-price, - .discounted-price { - font-size: var(--font-size-lg, 1rem); - } - } -} - - diff --git a/src/app/components/items-carousel/items-carousel.component.ts b/src/app/components/items-carousel/items-carousel.component.ts deleted file mode 100644 index 192e635..0000000 --- a/src/app/components/items-carousel/items-carousel.component.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Component, OnInit, signal, ChangeDetectionStrategy, inject } from '@angular/core'; -import { DecimalPipe } from '@angular/common'; -import { RouterLink } from '@angular/router'; -import { CarouselModule } from 'primeng/carousel'; -import { ButtonModule } from 'primeng/button'; -import { TagModule } from 'primeng/tag'; -import { CartService } from '../../services'; -import { Item } from '../../models'; -import { getDiscountedPrice, getMainImage, getBadgeClass, getTranslatedField } from '../../utils/item.utils'; -import { LanguageService } from '../../services/language.service'; -import { LangRoutePipe } from '../../pipes/lang-route.pipe'; -import { TranslatePipe } from '../../i18n/translate.pipe'; -import { ProductFacade } from '../../facades/platform/product.facade'; -import { IconComponent } from '../../shared/ui/icon/icon.component'; - -@Component({ - selector: 'app-items-carousel', - templateUrl: './items-carousel.component.html', - imports: [DecimalPipe, RouterLink, CarouselModule, ButtonModule, TagModule, LangRoutePipe, TranslatePipe, IconComponent], - styleUrls: ['./items-carousel.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class ItemsCarouselComponent implements OnInit { - products = signal([]); - loading = signal(true); - private readonly productFacade = inject(ProductFacade); - - responsiveOptions: { breakpoint: string; numVisible: number; numScroll: number }[] | undefined; - - constructor( - private cartService: CartService - ) {} - - ngOnInit() { - this.productFacade.getFeaturedProducts({ count: 10 }).subscribe({ - next: (result) => { - this.products.set(result.items); - this.loading.set(false); - }, - error: () => { - this.loading.set(false); - } - }); - - this.responsiveOptions = [ - { - breakpoint: '1400px', - numVisible: 5, - numScroll: 1 - }, - { - breakpoint: '1199px', - numVisible: 4, - numScroll: 1 - }, - { - breakpoint: '991px', - numVisible: 3, - numScroll: 1 - }, - { - breakpoint: '767px', - numVisible: 2, - numScroll: 1 - }, - { - breakpoint: '575px', - numVisible: 1, - numScroll: 1 - } - ]; - } - - getSeverity(remainings: string): 'success' | 'info' | 'warn' | 'danger' | 'secondary' | 'contrast' { - switch (remainings) { - case 'high': - return 'success'; - case 'low': - return 'warn'; - case 'out': - return 'danger'; - default: - return 'success'; - } - } - - getInventoryStatus(remainings: string): string { - switch (remainings) { - case 'high': - return 'INSTOCK'; - case 'low': - return 'LOWSTOCK'; - case 'out': - return 'OUTOFSTOCK'; - default: - return 'INSTOCK'; - } - } - - readonly getItemImage = getMainImage; - readonly getDiscountedPrice = getDiscountedPrice; - readonly getBadgeClass = getBadgeClass; - - private langService = inject(LanguageService); - itemName(product: Item): string { return getTranslatedField(product, 'name', this.langService.currentLanguage()); } - - addToCart(event: Event, item: Item): void { - event.preventDefault(); - event.stopPropagation(); - this.cartService.addItem(item.itemID, 1); - } -} - diff --git a/src/app/dynamic-renderer/widget-host/widget-host.service.ts b/src/app/dynamic-renderer/widget-host/widget-host.service.ts index f74c90b..5f26ad2 100644 --- a/src/app/dynamic-renderer/widget-host/widget-host.service.ts +++ b/src/app/dynamic-renderer/widget-host/widget-host.service.ts @@ -3,7 +3,7 @@ import { Observable, map, shareReplay } from 'rxjs'; import { WidgetConfig, SectionConfig } from '../../shared/models/config'; import { ConfigService } from '../../core/config/config.service'; import { RuntimeDiagnosticsService } from '../../core/runtime/runtime-diagnostics.service'; -import { UnknownWidgetComponent } from '../../widgets/ui'; +import { UnknownWidgetComponent } from '../../widgets/ui/unknown-widget.component'; import { ResolvedWidget } from '../../widgets/contracts/widget-component.contract'; import { DataSourceResolverService } from '../../widgets/resolvers/data-source-resolver.service'; import { WidgetRegistryService } from '../../widgets/registry/widget-registry.service'; diff --git a/src/app/guards/language.guard.ts b/src/app/guards/language.guard.ts index 0761bea..aa51706 100644 --- a/src/app/guards/language.guard.ts +++ b/src/app/guards/language.guard.ts @@ -1,16 +1,20 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { LanguageService } from '../services/language.service'; +import { TranslateService } from '../i18n/translate.service'; -export const languageGuard: CanActivateFn = (route, state) => { +export const languageGuard: CanActivateFn = async (route, state) => { const langService = inject(LanguageService); + const translateService = inject(TranslateService); const router = inject(Router); const lang = route.params['lang']; const langObj = langService.languages.find(l => l.code === lang); if (langObj?.enabled) { - // Valid and enabled language — set it and proceed + // Valid and enabled language — preload its translation pack (no-op for + // already-loaded packs), then set it and proceed. + await translateService.preloadLanguage(lang); langService.setLanguage(lang); return true; } diff --git a/src/app/i18n/translate.service.ts b/src/app/i18n/translate.service.ts index 7d59a36..da56906 100644 --- a/src/app/i18n/translate.service.ts +++ b/src/app/i18n/translate.service.ts @@ -1,22 +1,47 @@ -import { Injectable, computed, inject } from '@angular/core'; +import { Injectable, computed, inject, signal } from '@angular/core'; import { LanguageService } from '../services/language.service'; import { Translations } from './translations'; import { ru } from './ru'; -import { en } from './en'; -import { hy } from './hy'; -const translationMap: Record = { ru, en, hy }; +// 'ru' is the platform default language, so it's the only translation pack +// bundled eagerly. 'en'/'hy' are code-split and fetched on demand via +// preloadLanguage() (invoked from languageGuard, which awaits it before +// route activation - components never observe a partially-loaded pack). +const translationLoaders: Record Promise> = { + en: () => import('./en').then(m => m.en), + hy: () => import('./hy').then(m => m.hy), +}; @Injectable({ providedIn: 'root', }) export class TranslateService { private langService = inject(LanguageService); + private readonly translationCache = signal>({ ru }); readonly translations = computed( - () => translationMap[this.langService.currentLanguage()] ?? ru, + () => this.translationCache()[this.langService.currentLanguage()] ?? ru, ); + /** + * Ensures the given language's translation pack is loaded before it is + * activated. Resolves immediately for already-loaded packs (including + * the eagerly-bundled 'ru'); dynamically imports otherwise. + */ + async preloadLanguage(lang: string): Promise { + if (this.translationCache()[lang]) { + return; + } + + const loader = translationLoaders[lang]; + if (!loader) { + return; + } + + const pack = await loader(); + this.translationCache.update(cache => ({ ...cache, [lang]: pack })); + } + /** * Translate a dot-separated key with optional interpolation params. * Usage: t('cart.phoneMoreDigits', { count: 3 }) → "Введите ещё 3 цифр" diff --git a/src/styles.scss b/src/styles.scss index 2721ad1..c251fa7 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,8 +1,5 @@ /* Global Styles */ -/* PrimeNG and PrimeIcons */ -@import 'primeicons/primeicons.css'; - /* Google Fonts loaded via in index.html for non-blocking rendering */ /* Default CSS Variables - will be overridden by theme files */