From c3d1153f0e29de89cb158af3f396b9aaf29c216b Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sun, 5 Jul 2026 02:08:15 +0400 Subject: [PATCH] Sprint 8: add widget manifest and data source engine --- docs/Widget-Manifest-Report.md | 91 ++++++++ .../widget-host/widget-host.model.ts | 10 + .../widget-host/widget-host.service.ts | 80 +++++-- .../dynamic-page-layout.component.ts | 69 ++---- src/app/pages/home/home.component.html | 142 +----------- src/app/pages/home/home.component.ts | 127 +---------- .../contracts/widget-component.contract.ts | 4 +- .../widgets/contracts/widget-data.contract.ts | 48 ++++ .../contracts/widget-manifest.contract.ts | 24 ++ .../registry/widget-manifest.service.ts | 34 +++ .../widget-registry.bootstrap.service.ts | 7 +- .../resolvers/data-source-resolver.service.ts | 213 ++++++++++++++++++ .../widgets/ui/categories-widget.component.ts | 37 +++ .../ui/footer-navigation-widget.component.ts | 7 +- src/app/widgets/ui/hero-widget.component.ts | 21 +- src/app/widgets/ui/index.ts | 1 + .../ui/product-carousel-widget.component.ts | 44 ++-- src/assets/mock/bootstrap/bootstrap.json | 74 +++++- src/assets/mock/bootstrap/homepage.json | 42 +++- .../mock/bootstrap/widget-manifest.json | 167 +++++++++++++- 20 files changed, 867 insertions(+), 375 deletions(-) create mode 100644 docs/Widget-Manifest-Report.md create mode 100644 src/app/widgets/contracts/widget-data.contract.ts create mode 100644 src/app/widgets/contracts/widget-manifest.contract.ts create mode 100644 src/app/widgets/registry/widget-manifest.service.ts create mode 100644 src/app/widgets/resolvers/data-source-resolver.service.ts create mode 100644 src/app/widgets/ui/categories-widget.component.ts diff --git a/docs/Widget-Manifest-Report.md b/docs/Widget-Manifest-Report.md new file mode 100644 index 0000000..4bbea34 --- /dev/null +++ b/docs/Widget-Manifest-Report.md @@ -0,0 +1,91 @@ +# Widget Manifest Report + +## Scope + +Sprint 8 introduced a generic widget manifest and data-source engine for the runtime homepage path. The implementation stays within the frozen architecture and does not change Product Domain, Category Domain, authentication, payment, or backend APIs. + +The widget engine now resolves widget metadata, supported layouts, supported data sources, settings schema, and default settings from a manifest. Widgets receive only the section config and resolved data. + +## Implemented Changes + +### Widget Manifest + +- `src/app/widgets/contracts/widget-manifest.contract.ts` +- `src/app/widgets/registry/widget-manifest.service.ts` +- `src/assets/mock/bootstrap/widget-manifest.json` + +Added widget metadata for: + +- Hero +- Categories +- ProductCollection +- Banner +- Html +- Partners +- Footer + +Each widget definition now exposes: + +- supported layouts +- supported data sources +- settings schema +- default settings + +### Data Source Resolver + +- `src/app/widgets/resolvers/data-source-resolver.service.ts` + +Added a generic resolver that delegates to the existing facades: + +- `CategoryFacade` for root and parent category data +- `ProductFacade` for featured, latest, category, manual, and related product data + +Supported data source modes include: + +- ProductCollection: `featured`, `latest`, `category`, `manual`, `related`, `future` +- Categories: `root`, `parent`, `manual`, `future` + +### Widget Rendering + +- `src/app/dynamic-renderer/widget-host/widget-host.service.ts` +- `src/app/layouts/containers/dynamic-page-layout.component.ts` +- `src/app/widgets/ui/hero-widget.component.ts` +- `src/app/widgets/ui/categories-widget.component.ts` +- `src/app/widgets/ui/product-carousel-widget.component.ts` +- `src/app/widgets/ui/footer-navigation-widget.component.ts` + +Widgets now receive section config plus resolved data only. The layout resolves widgets asynchronously through the host service and renders the resolved component with those two inputs. + +### Homepage Bootstrap + +- `src/assets/mock/bootstrap/bootstrap.json` +- `src/assets/mock/bootstrap/homepage.json` +- `src/app/pages/home/home.component.ts` +- `src/app/pages/home/home.component.html` + +The homepage now resolves from the runtime section collection and includes: + +- Hero +- Categories +- ProductCollection + +## Validation + +Completed checks: + +- Existing homepage still renders. +- ProductCollection works through the data-source resolver. +- Categories work through the data-source resolver. +- Widget metadata exists in the manifest. +- Data-source resolution is isolated from widget components. +- Build passes. + +Build validation: + +```bash +npm run build +``` + +## Stop Point + +Sprint 8 widget-manifest and data-source-engine work is complete. Stop here and wait for approval before extending the manifest system to more pages or adding new widget types. diff --git a/src/app/dynamic-renderer/widget-host/widget-host.model.ts b/src/app/dynamic-renderer/widget-host/widget-host.model.ts index b9ff00d..e6709ad 100644 --- a/src/app/dynamic-renderer/widget-host/widget-host.model.ts +++ b/src/app/dynamic-renderer/widget-host/widget-host.model.ts @@ -1,4 +1,6 @@ +import { Type } from '@angular/core'; import { WidgetConfig } from '../../shared/models/config'; +import { SectionConfig } from '../../shared/models/config'; export interface WidgetRenderNode { id: string; @@ -9,3 +11,11 @@ export interface WidgetRenderNode { visible?: boolean; source: WidgetConfig; } + +export interface ResolvedWidgetRenderNode { + type: string; + version: string; + component: Type; + section: SectionConfig; + data: unknown; +} 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 9b73899..f74c90b 100644 --- a/src/app/dynamic-renderer/widget-host/widget-host.service.ts +++ b/src/app/dynamic-renderer/widget-host/widget-host.service.ts @@ -1,23 +1,77 @@ import { Injectable } from '@angular/core'; -import { WidgetConfig } from '../../shared/models/config'; -import { WidgetRegistryService } from '../../widgets/registry/widget-registry.service'; +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 { ResolvedWidget } from '../../widgets/contracts/widget-component.contract'; +import { DataSourceResolverService } from '../../widgets/resolvers/data-source-resolver.service'; +import { WidgetRegistryService } from '../../widgets/registry/widget-registry.service'; @Injectable({ providedIn: 'root' }) export class WidgetHostService { - constructor(private readonly registry: WidgetRegistryService) {} + private readonly resolvedWidgets = new Map>(); + private readonly loggedUnknownWidgets = new Set(); - resolveWidget(widget: WidgetConfig): ResolvedWidget | null { - const registered = this.registry.resolve(widget.type, widget.version); - if (!registered) { - return null; + constructor( + private readonly registry: WidgetRegistryService, + private readonly dataSourceResolver: DataSourceResolverService, + private readonly diagnostics: RuntimeDiagnosticsService, + private readonly configService: ConfigService + ) {} + + resolveWidget(widget: WidgetConfig, section: SectionConfig, pageId: string): Observable { + const cacheKey = `${pageId}:${section.id}:${widget.id}`; + const cached = this.resolvedWidgets.get(cacheKey); + if (cached) { + return cached; } - return { - type: registered.type, - version: registered.version, - component: registered.component, - props: widget.props ?? {} - }; + const registered = this.registry.resolve(widget.type, widget.version); + const resolved$ = this.dataSourceResolver.resolve(widget, section).pipe( + map((data) => { + if (!registered) { + this.logUnknownWidget(pageId, section.id, widget); + + return { + type: widget.type, + version: widget.version, + component: UnknownWidgetComponent, + section, + data + }; + } + + return { + type: registered.type, + version: registered.version, + component: registered.component, + section, + data + }; + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + this.resolvedWidgets.set(cacheKey, resolved$); + return resolved$; + } + + private logUnknownWidget(pageId: string, sectionId: string, widget: WidgetConfig): void { + const diagnosticKey = `${pageId}:${sectionId}:${widget.id}`; + if (this.loggedUnknownWidgets.has(diagnosticKey)) { + return; + } + + this.loggedUnknownWidgets.add(diagnosticKey); + + const tenantId = this.configService.getBootstrapSnapshot()?.tenant.id ?? 'unknown'; + this.diagnostics.logUnknownWidget({ + tenant: tenantId, + page: pageId, + section: sectionId, + widget: `${widget.type}@${widget.version}`, + reason: 'not_registered_in_widget_registry' + }); } } diff --git a/src/app/layouts/containers/dynamic-page-layout.component.ts b/src/app/layouts/containers/dynamic-page-layout.component.ts index 77ce61b..c87f889 100644 --- a/src/app/layouts/containers/dynamic-page-layout.component.ts +++ b/src/app/layouts/containers/dynamic-page-layout.component.ts @@ -1,12 +1,11 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { NgComponentOutlet } from '@angular/common'; +import { Observable } from 'rxjs'; import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model'; import { WidgetRenderNode } from '../../dynamic-renderer/widget-host/widget-host.model'; +import { ResolvedWidget } from '../../widgets/contracts/widget-component.contract'; import { WidgetHostService } from '../../dynamic-renderer/widget-host/widget-host.service'; -import { RuntimeDiagnosticsService } from '../../core/runtime/runtime-diagnostics.service'; -import { UnknownWidgetComponent } from '../../widgets/ui'; -import { ConfigService } from '../../core/config/config.service'; @Component({ selector: 'app-dynamic-page-layout', @@ -16,21 +15,21 @@ import { ConfigService } from '../../core/config/config.service'; @if (model) {
@for (section of model.sections; track section.id) { -
+
@for (widget of section.widgets; track widget.id) {
- @if (resolveWidget(widget, section.id); as resolved) { - + @if (resolveWidget(widget, section, model.id) | async; as resolved) { + }
} @@ -71,42 +70,10 @@ import { ConfigService } from '../../core/config/config.service'; }) export class DynamicPageLayoutComponent { @Input() model: PageRenderModel | null = null; - private readonly loggedUnknownWidgets = new Set(); - constructor( - private readonly widgetHost: WidgetHostService, - private readonly diagnostics: RuntimeDiagnosticsService, - private readonly configService: ConfigService - ) {} + constructor(private readonly widgetHost: WidgetHostService) {} - resolveWidget(widget: WidgetRenderNode, sectionId?: string) { - const resolved = this.widgetHost.resolveWidget(widget.source); - if (resolved) { - return resolved; - } - - const diagnosticKey = `${this.model?.id ?? 'unknown'}:${sectionId ?? 'unknown'}:${widget.id}`; - if (!this.loggedUnknownWidgets.has(diagnosticKey)) { - this.loggedUnknownWidgets.add(diagnosticKey); - - const tenantId = this.configService.getBootstrapSnapshot()?.tenant.id ?? 'unknown'; - this.diagnostics.logUnknownWidget({ - tenant: tenantId, - page: this.model?.key ?? 'unknown', - section: sectionId ?? 'unknown', - widget: `${widget.type}@${widget.version}`, - reason: 'not_registered_in_widget_registry' - }); - } - - return { - type: widget.type, - version: widget.version, - component: UnknownWidgetComponent, - props: { - widgetType: widget.type, - widgetVersion: widget.version - } - }; + resolveWidget(widget: WidgetRenderNode, section: PageRenderModel['sections'][number], pageId: string): Observable { + return this.widgetHost.resolveWidget(widget.source, section.source, pageId); } } diff --git a/src/app/pages/home/home.component.html b/src/app/pages/home/home.component.html index f1b0a60..4536971 100644 --- a/src/app/pages/home/home.component.html +++ b/src/app/pages/home/home.component.html @@ -1,139 +1,7 @@ - -@if (isMarketplaceNovo) { -
- @if (pageModel()) { - - } - - @if (loading()) { -
-
-
-
-
-
- @for (i of skeletonSlots; track i) { -
-
-
-
-
-
-
- } -
-
- } - - @if (error()) { -
-
⚠️
-

{{ 'home.errorTitle' | translate }}

-

{{ error() }}

- -
- } - - @if (!loading() && !error()) { -
-
-

{{ 'home.categoriesTitle' | translate }}

-

{{ 'home.categoriesSubtitle' | translate }}

-
- - @if (topLevelCategories().length === 0) { -
-
📦
-

{{ 'home.categoriesEmpty' | translate }}

-

{{ 'home.categoriesEmptyDesc' | translate }}

-
- } @else { - - } -
- } -
+@if (loading()) { +
Loading homepage...
+} @else if (pageModel()) { + } @else { - -
- @if (pageModel()) { - - } - - @if (loading()) { -
-
-
- @for (i of skeletonSlots; track i) { -
-
-
-
-
-
-
- } -
-
- } - - @if (error()) { -
-

{{ error() }}

- -
- } - - @if (!loading() && !error()) { -
-

{{ 'home.catalogTitle' | translate }}

- @if (topLevelCategories().length === 0) { -
-
📦
-

{{ 'home.emptyCategoriesDexar' | translate }}

-

{{ 'home.categoriesSoonDexar' | translate }}

-
- } @else { - - } -
- } -
+
No page configuration found.
} diff --git a/src/app/pages/home/home.component.ts b/src/app/pages/home/home.component.ts index 7f0c756..83bed2f 100644 --- a/src/app/pages/home/home.component.ts +++ b/src/app/pages/home/home.component.ts @@ -1,156 +1,41 @@ -import { Component, OnInit, signal, computed, ChangeDetectionStrategy } from '@angular/core'; -import { Router, RouterLink } from '@angular/router'; -import { LanguageService } from '../../services'; +import { Component, OnInit, signal, ChangeDetectionStrategy } from '@angular/core'; +import { Router } from '@angular/router'; import { DynamicPageLayoutComponent } from '../../layouts/containers/dynamic-page-layout.component'; import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model'; import { WebsiteRuntimeFacade } from '../../facades/website/website-runtime.facade'; -import { LangRoutePipe } from '../../pipes/lang-route.pipe'; -import { TranslatePipe } from '../../i18n/translate.pipe'; -import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade'; -import { CategoryFacade } from '../../facades/platform/category.facade'; -import { Category } from '../../core/categories/models/category-domain.model'; @Component({ selector: 'app-home', standalone: true, - imports: [RouterLink, DynamicPageLayoutComponent, LangRoutePipe, TranslatePipe], + imports: [DynamicPageLayoutComponent], templateUrl: './home.component.html', styleUrls: ['./home.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class HomeComponent implements OnInit { constructor( - private router: Router, - private langService: LanguageService, - private readonly uiRuntime: UiRuntimeFacade, - private readonly categoryFacade: CategoryFacade, + private readonly router: Router, private readonly websiteRuntime: WebsiteRuntimeFacade ) {} - categories = signal([]); loading = signal(true); error = signal(null); readonly pageModel = signal(null); - readonly skeletonSlots = Array.from({ length: 6 }); - - // Memoized computed values for performance - topLevelCategories = computed(() => { - return this.categories() - .filter(cat => this.isDisplayableTopLevelCategory(cat)) - .sort((a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity)); - }); - - // Memoized item count lookup - private itemCountMap = computed(() => { - const map = new Map(); - this.categories().forEach(cat => map.set(cat.id, cat.itemCount || 0)); - return map; - }); - - // Cache subcategories by parent ID - private subcategoriesCache = computed(() => { - const cache = new Map(); - this.categories().forEach(cat => { - const children = cat.children.filter(child => this.isDisplayableFlatSubcategory(child)); - if (children.length > 0) { - cache.set(cat.id, children); - } - }); - return cache; - }); - - get brandName(): string { - return this.uiRuntime.marketplaceDisplayName(); - } - - get isMarketplaceNovo(): boolean { - return this.uiRuntime.isMarketplaceVariant('novo'); - } ngOnInit(): void { this.loadHomepageSections(); - this.loadCategories(); } private loadHomepageSections(): void { this.websiteRuntime.getPageRenderModelForUrl(this.router.url).subscribe({ next: (model) => { this.pageModel.set(model); + this.loading.set(false); }, error: () => { this.pageModel.set(null); + this.loading.set(false); } }); } - - loadCategories(): void { - this.loading.set(true); - this.error.set(null); - this.categoryFacade.getRootCategories().subscribe({ - next: (categories) => { - this.categories.set(categories); - this.loading.set(false); - }, - error: (err) => { - this.error.set('Failed to load categories'); - this.loading.set(false); - console.error('Error loading categories:', err); - } - }); - } - - getItemCount(categoryId: number): number { - return this.itemCountMap().get(categoryId) || 0; - } - - getSubCategories(parentId: number): Category[] { - return this.subcategoriesCache().get(parentId) || []; - } - - private isDisplayableFlatSubcategory(category: Category): boolean { - return category.visible !== false - && ((category.itemCount ?? 0) > 0 || category.children.length > 0); - } - - private isDisplayableTopLevelCategory(category: Category): boolean { - return category.visible !== false - && ( - (category.itemCount ?? 0) > 0 - || category.children.length > 0 - || this.getSubCategories(category.id).length > 0 - ); - } - - navigateToSearch(): void { - const lang = this.langService.currentLanguage(); - this.router.navigate([`/${lang}/search`]); - } - - categoryName(cat: Category): string { - return cat.translations[this.langService.currentLanguage()]?.title ?? cat.title; - } - - scrollToCatalog(): void { - const target = document.getElementById('catalog'); - if (!target) return; - - const targetY = target.getBoundingClientRect().top + window.scrollY; - const startY = window.scrollY; - const distance = targetY - startY; - const duration = 1200; - let start: number | null = null; - - const easeInOutCubic = (t: number) => - t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; - - const step = (timestamp: number) => { - if (!start) start = timestamp; - const elapsed = timestamp - start; - const progress = Math.min(elapsed / duration, 1); - window.scrollTo(0, startY + distance * easeInOutCubic(progress)); - if (progress < 1) requestAnimationFrame(step); - }; - - requestAnimationFrame(step); - } } diff --git a/src/app/widgets/contracts/widget-component.contract.ts b/src/app/widgets/contracts/widget-component.contract.ts index e6e0d5d..252b9f6 100644 --- a/src/app/widgets/contracts/widget-component.contract.ts +++ b/src/app/widgets/contracts/widget-component.contract.ts @@ -1,4 +1,5 @@ import { Type } from '@angular/core'; +import { SectionConfig } from '../../shared/models/config'; export interface WidgetRenderContext { pageId: string; @@ -16,5 +17,6 @@ export interface ResolvedWidget { type: string; version: string; component: Type; - props: Record; + section: SectionConfig; + data: unknown; } diff --git a/src/app/widgets/contracts/widget-data.contract.ts b/src/app/widgets/contracts/widget-data.contract.ts new file mode 100644 index 0000000..7e88012 --- /dev/null +++ b/src/app/widgets/contracts/widget-data.contract.ts @@ -0,0 +1,48 @@ +import { Category } from '../../core/categories/models/category-domain.model'; +import { Product } from '../../core/products/models/product-domain.model'; +import { SectionConfig } from '../../shared/models/config'; + +export interface WidgetResolvedContext { + section: SectionConfig; + settings: Record; +} + +export interface HeroWidgetData extends WidgetResolvedContext { + title: string; + subtitle?: string; + ctaLabel?: string; +} + +export interface CategoriesWidgetData extends WidgetResolvedContext { + title: string; + categories: Category[]; + emptyMessage?: string; +} + +export interface ProductCollectionWidgetData extends WidgetResolvedContext { + title: string; + products: Product[]; + actionLabel?: string; + emptyMessage?: string; +} + +export interface BannerWidgetData extends WidgetResolvedContext { + title?: string; + subtitle?: string; + imageUrl?: string; + ctaLabel?: string; + ctaHref?: string; +} + +export interface HtmlWidgetData extends WidgetResolvedContext { + html: string; +} + +export interface PartnersWidgetData extends WidgetResolvedContext { + title?: string; + logos: Array<{ id: string; label: string; imageUrl: string; href?: string }>; +} + +export interface FooterWidgetData extends WidgetResolvedContext { + links: Array<{ id: string; label: string; href: string }>; +} \ No newline at end of file diff --git a/src/app/widgets/contracts/widget-manifest.contract.ts b/src/app/widgets/contracts/widget-manifest.contract.ts new file mode 100644 index 0000000..3bccd14 --- /dev/null +++ b/src/app/widgets/contracts/widget-manifest.contract.ts @@ -0,0 +1,24 @@ +export type WidgetLayoutSupport = 'stack' | 'grid' | 'hero' | 'carousel' | 'split'; + +export type WidgetDataSourceName = 'featured' | 'latest' | 'category' | 'manual' | 'related' | 'future' | 'root' | 'parent'; + +export interface WidgetSettingsSchema { + type: 'object'; + properties: Record; + required?: string[]; +} + +export interface WidgetManifestEntry { + type: string; + version: string; + componentKey: string; + supportedLayouts: WidgetLayoutSupport[]; + supportedDataSources: WidgetDataSourceName[]; + settingsSchema: WidgetSettingsSchema; + defaultSettings: Record; + enabled?: boolean; +} + +export interface WidgetManifestFile { + widgets: WidgetManifestEntry[]; +} \ No newline at end of file diff --git a/src/app/widgets/registry/widget-manifest.service.ts b/src/app/widgets/registry/widget-manifest.service.ts new file mode 100644 index 0000000..129f627 --- /dev/null +++ b/src/app/widgets/registry/widget-manifest.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, catchError, map, of, shareReplay } from 'rxjs'; +import { WidgetManifestEntry, WidgetManifestFile } from '../contracts/widget-manifest.contract'; + +@Injectable({ providedIn: 'root' }) +export class WidgetManifestService { + private readonly manifestUrl = '/assets/mock/bootstrap/widget-manifest.json'; + private manifest$?: Observable; + + constructor(private readonly http: HttpClient) {} + + getManifest(): Observable { + if (!this.manifest$) { + this.manifest$ = this.http.get(this.manifestUrl).pipe( + shareReplay({ bufferSize: 1, refCount: true }), + catchError(() => { + this.manifest$ = undefined; + return of({ widgets: [] }); + }) + ); + } + + return this.manifest$; + } + + getWidgets(): Observable { + return this.getManifest().pipe(map((manifest) => manifest.widgets ?? [])); + } + + getWidget(type: string): Observable { + return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type))); + } +} \ No newline at end of file diff --git a/src/app/widgets/registry/widget-registry.bootstrap.service.ts b/src/app/widgets/registry/widget-registry.bootstrap.service.ts index efecb36..b7335d5 100644 --- a/src/app/widgets/registry/widget-registry.bootstrap.service.ts +++ b/src/app/widgets/registry/widget-registry.bootstrap.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, map, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { HeroWidgetComponent, FooterNavigationWidgetComponent, ProductCarouselWidgetComponent } from '../ui'; +import { CategoriesWidgetComponent, FooterNavigationWidgetComponent, HeroWidgetComponent, ProductCarouselWidgetComponent } from '../ui'; import { RegisteredWidget } from '../contracts/widget-component.contract'; import { WidgetRegistryService } from './widget-registry.service'; @@ -19,8 +19,11 @@ interface WidgetManifest { const APPROVED_WIDGET_COMPONENTS: Record = { 'hero': HeroWidgetComponent, + 'categories': CategoriesWidgetComponent, + 'footer': FooterNavigationWidgetComponent, 'footer-navigation': FooterNavigationWidgetComponent, - 'product-carousel': ProductCarouselWidgetComponent + 'product-carousel': ProductCarouselWidgetComponent, + 'product-collection': ProductCarouselWidgetComponent }; @Injectable({ providedIn: 'root' }) diff --git a/src/app/widgets/resolvers/data-source-resolver.service.ts b/src/app/widgets/resolvers/data-source-resolver.service.ts new file mode 100644 index 0000000..d04a6e3 --- /dev/null +++ b/src/app/widgets/resolvers/data-source-resolver.service.ts @@ -0,0 +1,213 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable, of, map, switchMap } from 'rxjs'; +import { CategoryFacade } from '../../facades/platform/category.facade'; +import { ProductFacade } from '../../facades/platform/product.facade'; +import { SectionConfig, WidgetConfig } from '../../shared/models/config'; +import { WidgetManifestEntry, WidgetDataSourceName } from '../contracts/widget-manifest.contract'; +import { BannerWidgetData, CategoriesWidgetData, FooterWidgetData, HeroWidgetData, HtmlWidgetData, PartnersWidgetData, ProductCollectionWidgetData } from '../contracts/widget-data.contract'; +import { WidgetManifestService } from '../registry/widget-manifest.service'; +import { Category } from '../../core/categories/models/category-domain.model'; +import { Product } from '../../core/products/models/product-domain.model'; + +@Injectable({ providedIn: 'root' }) +export class DataSourceResolverService { + private readonly widgetManifest = inject(WidgetManifestService); + private readonly categoryFacade = inject(CategoryFacade); + private readonly productFacade = inject(ProductFacade); + + resolve(widget: WidgetConfig, section: SectionConfig): Observable { + return this.widgetManifest.getWidget(widget.type).pipe( + switchMap((definition) => this.resolveByDefinition(definition, widget, section)) + ); + } + + private resolveByDefinition(definition: WidgetManifestEntry | undefined, widget: WidgetConfig, section: SectionConfig): Observable { + const settings = this.resolveSettings(definition, widget); + const source = this.resolveSource(widget, settings, definition); + + switch (widget.type) { + case 'hero': + return of(this.toHeroData(section, settings)); + case 'categories': + return this.resolveCategories(section, widget, settings, source); + case 'product-collection': + case 'product-carousel': + return this.resolveProductCollection(section, widget, settings, source); + case 'banner': + return of(this.toBannerData(section, settings)); + case 'html': + return of(this.toHtmlData(section, settings)); + case 'partners': + return of(this.toPartnersData(section, settings)); + case 'footer': + case 'footer-navigation': + return of(this.toFooterData(section, settings)); + default: + return of({ section, settings }); + } + } + + private resolveSettings(definition: WidgetManifestEntry | undefined, widget: WidgetConfig): Record { + return { + ...(definition?.defaultSettings ?? {}), + ...(widget.props ?? {}) + }; + } + + private resolveSource(widget: WidgetConfig, settings: Record, definition?: WidgetManifestEntry): WidgetDataSourceName | undefined { + const source = settings['source'] ?? widget.props?.['source'] ?? definition?.defaultSettings?.['source']; + return typeof source === 'string' ? source as WidgetDataSourceName : undefined; + } + + private toHeroData(section: SectionConfig, settings: Record): HeroWidgetData { + return { + section, + settings, + title: String(settings['title'] ?? ''), + subtitle: settings['subtitle'] != null ? String(settings['subtitle']) : undefined, + ctaLabel: settings['ctaLabel'] != null ? String(settings['ctaLabel']) : undefined + }; + } + + private resolveCategories(section: SectionConfig, widget: WidgetConfig, settings: Record, source?: WidgetDataSourceName): Observable { + const title = String(settings['title'] ?? ''); + const emptyMessage = settings['emptyMessage'] != null ? String(settings['emptyMessage']) : undefined; + + switch (source) { + case 'parent': { + const parentId = this.toNumber(settings['parentId']); + if (parentId == null) { + return of({ section, settings, title, categories: [], emptyMessage }); + } + + return this.categoryFacade.getChildren(parentId).pipe( + map((categories) => ({ section, settings, title, categories, emptyMessage })) + ); + } + case 'manual': { + const categories = Array.isArray(settings['categories']) ? settings['categories'] as Category[] : []; + return of({ section, settings, title, categories, emptyMessage }); + } + case 'future': + return of({ section, settings, title, categories: [], emptyMessage }); + case 'root': + default: + return this.categoryFacade.getRootCategories().pipe( + map((categories) => ({ section, settings, title, categories, emptyMessage })) + ); + } + } + + private resolveProductCollection(section: SectionConfig, widget: WidgetConfig, settings: Record, source?: WidgetDataSourceName): Observable { + const title = String(settings['title'] ?? ''); + const actionLabel = settings['actionLabel'] != null ? String(settings['actionLabel']) : undefined; + const emptyMessage = settings['emptyMessage'] != null ? String(settings['emptyMessage']) : undefined; + const count = this.toNumber(settings['count']) ?? 8; + const categoryId = this.toNumber(settings['categoryId']); + + switch (source) { + case 'latest': + return this.productFacade.getLatestProducts({ count }).pipe( + map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage)) + ); + case 'category': + if (categoryId == null) { + return of(this.toProductCollectionData(section, settings, title, [], actionLabel, emptyMessage)); + } + + return this.productFacade.getProductsByCategory(categoryId, { count }).pipe( + map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage)) + ); + case 'related': { + const productId = this.toNumber(settings['productId']); + if (productId == null) { + return of(this.toProductCollectionData(section, settings, title, [], actionLabel, emptyMessage)); + } + + return this.productFacade.getRelatedProducts({ + productID: productId, + categoryID: categoryId ?? undefined, + count + }).pipe( + map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage)) + ); + } + case 'manual': { + const products = Array.isArray(settings['products']) ? settings['products'] as Product[] : []; + return of(this.toProductCollectionData(section, settings, title, products, actionLabel, emptyMessage)); + } + case 'future': + return of(this.toProductCollectionData(section, settings, title, [], actionLabel, emptyMessage)); + case 'featured': + default: + return this.productFacade.getFeaturedProducts({ count }).pipe( + map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage)) + ); + } + } + + private toProductCollectionData(section: SectionConfig, settings: Record, title: string, products: Product[], actionLabel?: string, emptyMessage?: string): ProductCollectionWidgetData { + return { + section, + settings, + title, + products, + actionLabel, + emptyMessage + }; + } + + private toBannerData(section: SectionConfig, settings: Record): BannerWidgetData { + return { + section, + settings, + title: settings['title'] != null ? String(settings['title']) : undefined, + subtitle: settings['subtitle'] != null ? String(settings['subtitle']) : undefined, + imageUrl: settings['imageUrl'] != null ? String(settings['imageUrl']) : undefined, + ctaLabel: settings['ctaLabel'] != null ? String(settings['ctaLabel']) : undefined, + ctaHref: settings['ctaHref'] != null ? String(settings['ctaHref']) : undefined + }; + } + + private toHtmlData(section: SectionConfig, settings: Record): HtmlWidgetData { + return { + section, + settings, + html: String(settings['html'] ?? '') + }; + } + + private toPartnersData(section: SectionConfig, settings: Record): PartnersWidgetData { + const logos = Array.isArray(settings['logos']) ? settings['logos'] as PartnersWidgetData['logos'] : []; + + return { + section, + settings, + title: settings['title'] != null ? String(settings['title']) : undefined, + logos + }; + } + + private toFooterData(section: SectionConfig, settings: Record): FooterWidgetData { + const links = Array.isArray(settings['links']) ? settings['links'] as FooterWidgetData['links'] : []; + + return { + section, + settings, + links + }; + } + + private toNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; + } +} \ No newline at end of file diff --git a/src/app/widgets/ui/categories-widget.component.ts b/src/app/widgets/ui/categories-widget.component.ts new file mode 100644 index 0000000..63c103e --- /dev/null +++ b/src/app/widgets/ui/categories-widget.component.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { SectionConfig } from '../../shared/models/config'; +import { CatalogCategoryGridComponent } from '../../features/website/catalog/components/category-grid/category-grid.component'; +import { CategoriesWidgetData } from '../contracts/widget-data.contract'; + +@Component({ + selector: 'app-categories-widget', + standalone: true, + imports: [CommonModule, CatalogCategoryGridComponent], + template: ` +
+ @if (data; as widgetData) { + @if (widgetData.title) { +

{{ widgetData.title }}

+ } + + @if (widgetData.categories.length) { + + } @else if (widgetData.emptyMessage) { +

{{ widgetData.emptyMessage }}

+ } + } +
+ `, + styles: [ + ` + .categories-widget { padding: 1rem 0; display: grid; gap: 1rem; } + .categories-widget__empty { margin: 0; color: var(--text-secondary, #667a77); } + ` + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CategoriesWidgetComponent { + @Input() section: SectionConfig | null = null; + @Input() data: CategoriesWidgetData | null = null; +} \ No newline at end of file diff --git a/src/app/widgets/ui/footer-navigation-widget.component.ts b/src/app/widgets/ui/footer-navigation-widget.component.ts index fe646c4..549ebb1 100644 --- a/src/app/widgets/ui/footer-navigation-widget.component.ts +++ b/src/app/widgets/ui/footer-navigation-widget.component.ts @@ -1,5 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { SectionConfig } from '../../shared/models/config'; +import { FooterWidgetData } from '../contracts/widget-data.contract'; export interface FooterNavigationLink { id: string; @@ -15,7 +17,7 @@ export interface FooterNavigationLink {