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 */