diff --git a/angular.json b/angular.json index fb3c9a8..97e9fdf 100644 --- a/angular.json +++ b/angular.json @@ -96,8 +96,7 @@ "dexarmarket.ru", "dexar.market", "localhost" - ], - "proxyConfig": "proxy.conf.json" + ] }, "builder": "@angular/build:dev-server", "configurations": { diff --git a/docs/Catalog-Module-Report.md b/docs/Catalog-Module-Report.md new file mode 100644 index 0000000..c08bd83 --- /dev/null +++ b/docs/Catalog-Module-Report.md @@ -0,0 +1,148 @@ +# Catalog Module Report + +## Scope + +Sprint 5 added a Catalog Module on the frozen platform architecture. No backend APIs, authentication, payment, or bootstrap contracts were changed. + +The catalog uses existing domain boundaries: + +- Category data: `CategoryFacade` -> `CategoryService` -> Category Repository -> existing `GET /category` +- Product data: `ProductFacade` -> `ProductDataService` -> Product Provider -> existing product/category item endpoints + +## Implemented Module + +### Catalog Container + +- `src/app/features/website/catalog/containers/catalog-container.component.ts` +- `src/app/features/website/catalog/containers/catalog-container.component.html` +- `src/app/features/website/catalog/containers/catalog-container.component.scss` + +Responsibilities implemented: + +- Reads the route category id. +- Requests category data through `CategoryFacade` only. +- Requests product data through `ProductFacade` only. +- Determines whether the current category has child categories. +- Renders category grid when child categories exist. +- Renders product grid when no child categories exist. +- Supports root catalog entry with root categories. +- Handles loading, empty, and error states. +- Cancels prior category/product data subscriptions when the route changes. + +No `HttpClient`, backend DTO, auth, payment, bootstrap, or tenant-specific logic is used in the container. + +### Category Grid + +- `src/app/features/website/catalog/components/category-grid/category-grid.component.ts` +- `src/app/features/website/catalog/components/category-grid/category-grid.component.html` +- `src/app/features/website/catalog/components/category-grid/category-grid.component.scss` + +Reusable category grid implemented with: + +- Input: `Category[]` +- Output: selected `Category` +- Responsive grid layout +- Domain model only +- No data fetching +- No backend DTOs + +### Product Grid + +- `src/app/features/website/catalog/components/product-grid/product-grid.component.ts` +- `src/app/features/website/catalog/components/product-grid/product-grid.component.html` +- `src/app/features/website/catalog/components/product-grid/product-grid.component.scss` + +Reusable product grid implemented with: + +- Input: `Product[]` +- Output: selected `Product` +- Output: add-to-cart payload +- Output: product preview id +- Responsive grid layout +- Uses existing reusable product card +- No `HttpClient` +- No backend DTOs + +### Product Card Compatibility + +- `src/app/components/product-card/product-card.component.ts` +- `src/app/components/product-card/product-card.component.html` + +Updated the reusable product card to depend on the Product Domain type and added an explicit selected output. + +The product card remains input/output-only and does not use services, storage, `HttpClient`, or environment configuration. It displays image, title, price, discount, badges, and stock. + +### Catalog State + +- `src/app/features/website/catalog/models/catalog-state.model.ts` + +Prepared future state architecture for: + +- Category +- Search +- Sort +- Price range +- Attributes +- Pagination +- Filters + +Backend filtering was intentionally not implemented in this sprint. + +## Navigation + +Updated routes in `src/app/app.routes.ts`: + +- `/catalog` +- `/catalog/:id` + +Both routes load the same catalog container. Legacy category URLs redirect to the catalog route: + +- `/category/:id` -> `/catalog/:id` +- `/category/:id/items` -> `/catalog/:id` + +Home category links now point to `/catalog/:id`. + +## Unlimited Category Depth + +Unlimited nesting is supported by the Category Domain tree utilities from Sprint 4. The catalog container does not assume a fixed depth. For any category id, it asks `CategoryFacade.getChildren(categoryId)`: + +- if children exist, it renders the category grid +- if no children exist, it loads the product grid + +This same decision repeats for every category route depth. + +## Localization + +Added catalog translations in: + +- `src/app/i18n/en.ts` +- `src/app/i18n/ru.ts` +- `src/app/i18n/hy.ts` +- `src/app/i18n/translations.ts` + +## Validation + +Completed checks: + +- Unlimited category depth is supported through facade child lookup and recursive category domain tree utilities. +- Product grid is reusable and consumes `Product[]`. +- Category grid is reusable and consumes `Category[]`. +- Catalog components use domain models only. +- Catalog data requests go through facades only. +- DTOs remain isolated outside the catalog module. +- Catalog module has no `HttpClient` usage. +- Product card has no services, storage, `HttpClient`, or environment usage. +- Authentication was not modified. +- Payment was not modified. +- Bootstrap contracts were not modified. +- Backend APIs were not modified. + +Build validation passed: + +```bash +npm run build +``` + +## Stop Point + +Catalog Module implementation is complete for Sprint 5. Stop here for approval before starting the next module or any Builder/Backoffice work. diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index ef1eb84..fb6451d 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -7,13 +7,23 @@ const coreRoutes: Routes = [ path: '', loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent) }, + { + path: 'catalog', + loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent) + }, + { + path: 'catalog/:id', + loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent) + }, { path: 'category/:id', - loadComponent: () => import('./pages/category/subcategories.component').then(m => m.SubcategoriesComponent) + redirectTo: 'catalog/:id', + pathMatch: 'full' }, { path: 'category/:id/items', - loadComponent: () => import('./pages/category/category.component').then(m => m.CategoryComponent) + redirectTo: 'catalog/:id', + pathMatch: 'full' }, { path: 'item/:id', diff --git a/src/app/components/product-card/product-card.component.html b/src/app/components/product-card/product-card.component.html index db7a67a..ab2406f 100644 --- a/src/app/components/product-card/product-card.component.html +++ b/src/app/components/product-card/product-card.component.html @@ -1,5 +1,5 @@
- +
@if (item.discount > 0) { diff --git a/src/app/components/product-card/product-card.component.ts b/src/app/components/product-card/product-card.component.ts index f516c36..67f4f13 100644 --- a/src/app/components/product-card/product-card.component.ts +++ b/src/app/components/product-card/product-card.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { DecimalPipe } from '@angular/common'; import { RouterLink } from '@angular/router'; -import { Item } from '../../models'; +import { Product } from '../../core/products/models/product-domain.model'; import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { getBadgeClass, getDiscountedPrice, getMainImage } from '../../utils/item.utils'; @@ -16,7 +16,7 @@ export type ProductCardAppearance = 'standard' | 'compact'; changeDetection: ChangeDetectionStrategy.OnPush }) export class ProductCardComponent { - @Input({ required: true }) item!: Item; + @Input({ required: true }) item!: Product; @Input() title = ''; @Input() description = ''; @Input() addToCartLabel = 'Add to cart'; @@ -27,6 +27,7 @@ export class ProductCardComponent { @Output() addToCart = new EventEmitter<{ itemID: number; event: Event }>(); @Output() preview = new EventEmitter(); + @Output() selected = new EventEmitter<{ itemID: number; event: Event }>(); readonly getMainImage = getMainImage; readonly getDiscountedPrice = getDiscountedPrice; @@ -35,4 +36,8 @@ export class ProductCardComponent { onAddToCart(event: Event): void { this.addToCart.emit({ itemID: this.item.itemID, event }); } + + onSelected(event: Event): void { + this.selected.emit({ itemID: this.item.itemID, event }); + } } diff --git a/src/app/features/website/catalog/components/category-grid/category-grid.component.html b/src/app/features/website/catalog/components/category-grid/category-grid.component.html new file mode 100644 index 0000000..a46f1f8 --- /dev/null +++ b/src/app/features/website/catalog/components/category-grid/category-grid.component.html @@ -0,0 +1,24 @@ +
+ @for (category of categories; track trackByCategoryId($index, category)) { + + } +
diff --git a/src/app/features/website/catalog/components/category-grid/category-grid.component.scss b/src/app/features/website/catalog/components/category-grid/category-grid.component.scss new file mode 100644 index 0000000..b96fd3e --- /dev/null +++ b/src/app/features/website/catalog/components/category-grid/category-grid.component.scss @@ -0,0 +1,112 @@ +.catalog-category-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 24px; +} + +.catalog-category-card { + display: flex; + flex-direction: column; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; + color: inherit; + transition: transform 0.2s ease; + + &:hover { + transform: translateY(-4px); + } + + &:focus-visible { + outline: 3px solid rgba(73, 118, 113, 0.28); + outline-offset: 4px; + border-radius: 13px; + } +} + +.catalog-category-image { + width: 100%; + aspect-ratio: 4 / 3; + border: 1px solid #d3dad9; + border-radius: 13px 13px 0 0; + box-shadow: 0 3px 4px rgba(0, 0, 0, 0.15); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.catalog-category-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + color: #497671; + font-size: 4rem; + font-weight: 800; + background: linear-gradient(135deg, #f7f8f8 0%, #e6eceb 100%); +} + +.catalog-category-content { + min-height: 88px; + padding: 13px 16px; + border: 1px solid #d3dad9; + border-top: 0; + border-radius: 0 0 13px 13px; + box-shadow: 0 3px 4px rgba(0, 0, 0, 0.15); + background: #f5f3f9; + display: flex; + flex-direction: column; + gap: 8px; +} + +.catalog-category-title { + color: #1e3c38; + font-size: 1rem; + font-weight: 800; + line-height: 1.25; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.catalog-category-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + + span { + padding: 2px 7px; + border-radius: 999px; + background: rgba(73, 118, 113, 0.1); + color: #3d635f; + font-size: 0.8rem; + font-weight: 700; + white-space: nowrap; + } +} + +@media (max-width: 640px) { + .catalog-category-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } + + .catalog-category-content { + min-height: 84px; + padding: 10px 12px; + } +} diff --git a/src/app/features/website/catalog/components/category-grid/category-grid.component.ts b/src/app/features/website/catalog/components/category-grid/category-grid.component.ts new file mode 100644 index 0000000..6b55518 --- /dev/null +++ b/src/app/features/website/catalog/components/category-grid/category-grid.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { Category } from '../../../../../core/categories/models/category-domain.model'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; + +@Component({ + selector: 'app-catalog-category-grid', + standalone: true, + imports: [TranslatePipe], + templateUrl: './category-grid.component.html', + styleUrls: ['./category-grid.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CatalogCategoryGridComponent { + @Input({ required: true }) categories: Category[] = []; + + @Output() categorySelected = new EventEmitter(); + + trackByCategoryId(_index: number, category: Category): number { + return category.id; + } +} diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.html b/src/app/features/website/catalog/components/product-grid/product-grid.component.html new file mode 100644 index 0000000..6877de4 --- /dev/null +++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.html @@ -0,0 +1,18 @@ +
+ @for (product of products; track trackByItemId($index, product)) { +
+ +
+ } +
diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.scss b/src/app/features/website/catalog/components/product-grid/product-grid.component.scss new file mode 100644 index 0000000..47361da --- /dev/null +++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.scss @@ -0,0 +1,17 @@ +.catalog-product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 24px; + align-items: stretch; +} + +.catalog-product-shell { + min-width: 0; +} + +@media (max-width: 640px) { + .catalog-product-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } +} diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.ts b/src/app/features/website/catalog/components/product-grid/product-grid.component.ts new file mode 100644 index 0000000..0a19a5c --- /dev/null +++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output } from '@angular/core'; +import { Product } from '../../../../../core/products/models/product-domain.model'; +import { ProductCardComponent } from '../../../../../components/product-card/product-card.component'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { LanguageService } from '../../../../../services/language.service'; +import { getTranslatedField, trackByItemId } from '../../../../../utils/item.utils'; + +@Component({ + selector: 'app-catalog-product-grid', + standalone: true, + imports: [ProductCardComponent, TranslatePipe], + templateUrl: './product-grid.component.html', + styleUrls: ['./product-grid.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CatalogProductGridComponent { + @Input({ required: true }) products: Product[] = []; + + @Output() productSelected = new EventEmitter(); + @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); + @Output() productPreview = new EventEmitter(); + + private readonly languageService = inject(LanguageService); + + readonly trackByItemId = trackByItemId; + + productTitle(product: Product): string { + return getTranslatedField(product, 'name', this.languageService.currentLanguage()); + } + + productDescription(product: Product): string { + return getTranslatedField(product, 'simpleDescription', this.languageService.currentLanguage()); + } + + onAddToCart(product: Product, event: Event): void { + this.addToCart.emit({ product, event }); + } +} diff --git a/src/app/features/website/catalog/containers/catalog-container.component.html b/src/app/features/website/catalog/containers/catalog-container.component.html new file mode 100644 index 0000000..3bfd59a --- /dev/null +++ b/src/app/features/website/catalog/containers/catalog-container.component.html @@ -0,0 +1,80 @@ +
+
+ {{ 'catalog.title' | translate }} + + @if (breadcrumb().length > 0) { + + } +
+ + @if (loading()) { +
+ @for (slot of skeletonSlots; track slot) { +
+
+
+
+
+ } +
+ } + + @if (error()) { +
+

{{ 'catalog.errorTitle' | translate }}

+

{{ error()! | translate }}

+ +
+ } + + @if (!loading() && !error() && viewMode() === 'categories') { +
+
+

{{ state().category?.title || ('catalog.allCategories' | translate) }}

+

{{ 'catalog.categoryHint' | translate }}

+
+ + @if (categories().length > 0) { + + } @else { +
+

{{ 'catalog.emptyTitle' | translate }}

+

{{ 'catalog.emptyCategories' | translate }}

+
+ } +
+ } + + @if (!loading() && !error() && viewMode() === 'products') { +
+
+

{{ state().category?.title || ('catalog.products' | translate) }}

+

{{ 'catalog.productHint' | translate }}

+
+ + @if (loadingProducts()) { +
{{ 'catalog.loadingProducts' | translate }}
+ } + + @if (!loadingProducts() && products().length > 0) { + + } @else if (!loadingProducts()) { +
+

{{ 'catalog.emptyTitle' | translate }}

+

{{ 'catalog.emptyProducts' | translate }}

+
+ } +
+ } +
diff --git a/src/app/features/website/catalog/containers/catalog-container.component.scss b/src/app/features/website/catalog/containers/catalog-container.component.scss new file mode 100644 index 0000000..b0960dc --- /dev/null +++ b/src/app/features/website/catalog/containers/catalog-container.component.scss @@ -0,0 +1,170 @@ +.catalog-page { + max-width: 1200px; + margin: 0 auto; + padding: 24px; + color: #1e3c38; +} + +.catalog-header { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 32px; +} + +.catalog-root-link { + width: fit-content; + color: #1e3c38; + font-size: 2rem; + font-weight: 900; + text-decoration: none; +} + +.catalog-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + color: #697777; + font-size: 0.95rem; + + a, + button { + border: 0; + padding: 0; + background: transparent; + color: #497671; + font: inherit; + font-weight: 700; + text-decoration: none; + cursor: pointer; + } +} + +.catalog-section { + display: flex; + flex-direction: column; + gap: 24px; +} + +.catalog-section-heading { + display: flex; + flex-direction: column; + gap: 6px; + + h1 { + margin: 0; + color: #1e3c38; + font-size: 1.75rem; + font-weight: 900; + } + + p { + margin: 0; + color: #697777; + font-size: 1rem; + } +} + +.catalog-loading { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 24px; +} + +.catalog-skeleton-card { + min-height: 260px; + border: 1px solid #d3dad9; + border-radius: 13px; + padding: 12px; + background: #f5f3f9; +} + +.catalog-skeleton-image, +.catalog-skeleton-line { + border-radius: 10px; + background: linear-gradient(90deg, #edf0ef 25%, #f8faf9 50%, #edf0ef 75%); + background-size: 200% 100%; + animation: catalog-shimmer 1.3s ease-in-out infinite; +} + +.catalog-skeleton-image { + aspect-ratio: 4 / 3; + margin-bottom: 14px; +} + +.catalog-skeleton-line { + height: 14px; + margin-top: 10px; +} + +.catalog-skeleton-title { + width: 72%; + height: 18px; +} + +.catalog-inline-loading, +.catalog-message { + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + text-align: center; + color: #697777; +} + +.catalog-message { + h1, + h2 { + margin: 0; + color: #1e3c38; + font-size: 1.35rem; + } + + p { + margin: 0; + max-width: 420px; + line-height: 1.5; + } + + button { + min-height: 42px; + padding: 0 22px; + border: 0; + border-radius: 13px; + background: #497671; + color: #fff; + font-weight: 800; + cursor: pointer; + } +} + +.catalog-message-error { + color: #991b1b; +} + +@keyframes catalog-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (max-width: 640px) { + .catalog-page { + padding: 16px; + } + + .catalog-root-link { + font-size: 1.5rem; + } + + .catalog-section-heading h1 { + font-size: 1.35rem; + } + + .catalog-loading { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } +} diff --git a/src/app/features/website/catalog/containers/catalog-container.component.ts b/src/app/features/website/catalog/containers/catalog-container.component.ts new file mode 100644 index 0000000..6c050ea --- /dev/null +++ b/src/app/features/website/catalog/containers/catalog-container.component.ts @@ -0,0 +1,170 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { combineLatest } from 'rxjs'; +import { Subscription } from 'rxjs'; +import { Category } from '../../../../core/categories/models/category-domain.model'; +import { Product } from '../../../../core/products/models/product-domain.model'; +import { CategoryFacade } from '../../../../facades/platform/category.facade'; +import { ProductFacade } from '../../../../facades/platform/product.facade'; +import { CartService } from '../../../../services'; +import { LanguageService } from '../../../../services/language.service'; +import { PrefetchService } from '../../../../services/prefetch.service'; +import { LangRoutePipe } from '../../../../pipes/lang-route.pipe'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component'; +import { CatalogProductGridComponent } from '../components/product-grid/product-grid.component'; +import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model'; + +type CatalogViewMode = 'categories' | 'products'; + +@Component({ + selector: 'app-catalog-container', + standalone: true, + imports: [RouterLink, LangRoutePipe, TranslatePipe, CatalogCategoryGridComponent, CatalogProductGridComponent], + templateUrl: './catalog-container.component.html', + styleUrls: ['./catalog-container.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CatalogContainerComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly categoryFacade = inject(CategoryFacade); + private readonly productFacade = inject(ProductFacade); + private readonly cartService = inject(CartService); + private readonly prefetchService = inject(PrefetchService); + private readonly languageService = inject(LanguageService); + + readonly state = signal(createInitialCatalogState()); + readonly categories = signal([]); + readonly products = signal([]); + readonly breadcrumb = signal([]); + readonly viewMode = signal('categories'); + readonly loading = signal(true); + readonly loadingProducts = signal(false); + readonly error = signal(null); + + readonly skeletonSlots = Array.from({ length: 8 }); + + private dataSubscription?: Subscription; + + constructor() { + this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe()); + + this.route.paramMap + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(params => { + const categoryId = Number(params.get('id')) || null; + this.enterCategory(categoryId); + }); + } + + enterCategory(categoryId: number | null): void { + this.dataSubscription?.unsubscribe(); + this.loading.set(true); + this.loadingProducts.set(false); + this.error.set(null); + this.categories.set([]); + this.products.set([]); + this.breadcrumb.set([]); + this.categoryFacade.selectCategory(categoryId); + this.state.set({ + ...createInitialCatalogState(), + filters: { + ...createInitialCatalogState().filters, + categoryIds: categoryId == null ? [] : [categoryId], + }, + }); + + if (categoryId == null) { + this.dataSubscription = this.categoryFacade.getRootCategories() + .subscribe({ + next: categories => this.renderCategories(null, categories, []), + error: () => this.setError('catalog.error'), + }); + return; + } + + this.dataSubscription = combineLatest([ + this.categoryFacade.getCategoryById(categoryId), + this.categoryFacade.getChildren(categoryId), + this.categoryFacade.getBreadcrumb(categoryId), + ]).subscribe({ + next: ([category, children, breadcrumb]) => { + this.breadcrumb.set(breadcrumb); + this.state.update(current => ({ ...current, category: category ?? null })); + + if (children.length > 0) { + this.renderCategories(category ?? null, children, breadcrumb); + return; + } + + this.loadProducts(categoryId); + }, + error: () => this.setError('catalog.error'), + }); + } + + selectCategory(category: Category): void { + this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`, category.id]); + } + + selectProduct(product: Product): void { + this.router.navigate([`/${this.languageService.currentLanguage()}/item`, product.itemID]); + } + + addToCart(payload: { product: Product; event: Event }): void { + payload.event.preventDefault(); + payload.event.stopPropagation(); + this.cartService.addItem(payload.product.itemID); + } + + previewProduct(productId: number): void { + this.prefetchService.prefetchItem(productId); + } + + retry(): void { + this.enterCategory(this.state().category?.id ?? null); + } + + private renderCategories(category: Category | null, categories: Category[], breadcrumb: Category[]): void { + const ordered = [...categories].sort((a, b) => a.priority - b.priority || a.id - b.id); + this.viewMode.set('categories'); + this.state.update(current => ({ ...current, category })); + this.categories.set(ordered); + this.products.set([]); + this.breadcrumb.set(breadcrumb); + this.loading.set(false); + } + + private loadProducts(categoryId: number): void { + const pagination = this.state().pagination; + this.viewMode.set('products'); + this.loadingProducts.set(true); + + this.dataSubscription = this.productFacade.getProductsByCategory(categoryId, { count: pagination.count, skip: pagination.skip }) + .subscribe({ + next: result => { + this.products.set(result.items); + this.state.update(current => ({ + ...current, + pagination: { + ...current.pagination, + total: result.total, + hasMore: result.items.length >= current.pagination.count, + }, + })); + this.loading.set(false); + this.loadingProducts.set(false); + }, + error: () => this.setError('catalog.error'), + }); + } + + private setError(message: string): void { + this.error.set(message); + this.loading.set(false); + this.loadingProducts.set(false); + } +} diff --git a/src/app/features/website/catalog/models/catalog-state.model.ts b/src/app/features/website/catalog/models/catalog-state.model.ts new file mode 100644 index 0000000..bc67a39 --- /dev/null +++ b/src/app/features/website/catalog/models/catalog-state.model.ts @@ -0,0 +1,54 @@ +import { Category } from '../../../../core/categories/models/category-domain.model'; + +export type CatalogSort = 'relevance' | 'price_asc' | 'price_desc' | 'popular' | 'rating' | 'latest'; + +export interface CatalogPriceRange { + min?: number; + max?: number; +} + +export interface CatalogPaginationState { + count: number; + skip: number; + total: number; + hasMore: boolean; +} + +export interface CatalogFilterState { + categoryIds: number[]; + priceRange: CatalogPriceRange; + attributes: Record; +} + +export interface CatalogState { + category: Category | null; + search: string; + sort: CatalogSort; + priceRange: CatalogPriceRange; + attributes: Record; + pagination: CatalogPaginationState; + filters: CatalogFilterState; +} + +export const DEFAULT_CATALOG_PAGE_SIZE = 24; + +export function createInitialCatalogState(): CatalogState { + return { + category: null, + search: '', + sort: 'relevance', + priceRange: {}, + attributes: {}, + pagination: { + count: DEFAULT_CATALOG_PAGE_SIZE, + skip: 0, + total: 0, + hasMore: true, + }, + filters: { + categoryIds: [], + priceRange: {}, + attributes: {}, + }, + }; +} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 866afda..ab024bc 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -144,6 +144,25 @@ export const en: Translations = { goHome: 'Go home', loading: 'Loading products...', }, + catalog: { + title: 'Catalog', + breadcrumb: 'Catalog breadcrumb', + allCategories: 'All categories', + products: 'Products', + loading: 'Loading catalog...', + loadingProducts: 'Loading products...', + errorTitle: 'Catalog is unavailable', + error: 'Could not load catalog data. Try again later.', + retry: 'Try again', + categoryHint: 'Choose a category to continue', + productHint: 'Browse products in this category', + emptyTitle: 'Nothing here yet', + emptyCategories: 'There are no child categories in this section yet.', + emptyProducts: 'There are no products in this category yet.', + addToCart: 'Add to cart', + categoriesCount: '{{count}} categories', + productsCount: '{{count}} products', + }, subcategories: { loading: 'Loading subcategories...', retry: 'Try again', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index fc97da6..2912350 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -144,6 +144,25 @@ export const hy: Translations = { goHome: 'Գլխավոր', loading: 'Ապրանքների բեռնում...', }, + catalog: { + title: 'Կատալոգ', + breadcrumb: 'Կատալոգի նավիգացիա', + allCategories: 'Բոլոր կատեգորիաները', + products: 'Ապրանքներ', + loading: 'Կատալոգի բեռնում...', + loadingProducts: 'Ապրանքների բեռնում...', + errorTitle: 'Կատալոգը հասանելի չէ', + error: 'Չհաջողվեց բեռնել կատալոգը։ Փորձեք ավելի ուշ։', + retry: 'Փորձել կրկին', + categoryHint: 'Ընտրեք կատեգորիա շարունակելու համար', + productHint: 'Ապրանքներ այս կատեգորիայում', + emptyTitle: 'Այստեղ դեռ դատարկ է', + emptyCategories: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան։', + emptyProducts: 'Այս կատեգորիայում դեռ ապրանքներ չկան։', + addToCart: 'Ավելացնել զամբյուղ', + categoriesCount: '{{count}} կատեգորիա', + productsCount: '{{count}} ապրանք', + }, subcategories: { loading: 'Ենթակատեգորիաների բեռնում...', retry: 'Փորձել կրկին', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index ea5673f..9fea86f 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -144,6 +144,25 @@ export const ru: Translations = { goHome: 'На главную', loading: 'Загрузка товаров...', }, + catalog: { + title: 'Каталог', + breadcrumb: 'Навигация по каталогу', + allCategories: 'Все категории', + products: 'Товары', + loading: 'Загрузка каталога...', + loadingProducts: 'Загрузка товаров...', + errorTitle: 'Каталог недоступен', + error: 'Не удалось загрузить каталог. Попробуйте позже.', + retry: 'Попробовать снова', + categoryHint: 'Выберите категорию, чтобы продолжить', + productHint: 'Товары в выбранной категории', + emptyTitle: 'Здесь пока пусто', + emptyCategories: 'В этом разделе пока нет дочерних категорий.', + emptyProducts: 'В этой категории пока нет товаров.', + addToCart: 'В корзину', + categoriesCount: '{{count}} категорий', + productsCount: '{{count}} товаров', + }, subcategories: { loading: 'Загрузка подкатегорий...', retry: 'Попробовать снова', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 39c2203..77dfe34 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -142,6 +142,25 @@ export interface Translations { goHome: string; loading: string; }; + catalog: { + title: string; + breadcrumb: string; + allCategories: string; + products: string; + loading: string; + loadingProducts: string; + errorTitle: string; + error: string; + retry: string; + categoryHint: string; + productHint: string; + emptyTitle: string; + emptyCategories: string; + emptyProducts: string; + addToCart: string; + categoriesCount: string; + productsCount: string; + }; subcategories: { loading: string; retry: string; diff --git a/src/app/pages/home/home.component.html b/src/app/pages/home/home.component.html index 7908823..4dd1288 100644 --- a/src/app/pages/home/home.component.html +++ b/src/app/pages/home/home.component.html @@ -63,7 +63,7 @@ } @else {