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 @@
-
- @if (loading()) {
-
-
-
{{ 'carousel.loading' | translate }}
-
- } @else if (products().length > 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 */