Sprint 8: add widget manifest and data source engine

This commit is contained in:
sdarbinyan
2026-07-05 02:08:15 +04:00
parent 91d9444875
commit c3d1153f0e
20 changed files with 867 additions and 375 deletions

View File

@@ -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.

View File

@@ -1,4 +1,6 @@
import { Type } from '@angular/core';
import { WidgetConfig } from '../../shared/models/config'; import { WidgetConfig } from '../../shared/models/config';
import { SectionConfig } from '../../shared/models/config';
export interface WidgetRenderNode { export interface WidgetRenderNode {
id: string; id: string;
@@ -9,3 +11,11 @@ export interface WidgetRenderNode {
visible?: boolean; visible?: boolean;
source: WidgetConfig; source: WidgetConfig;
} }
export interface ResolvedWidgetRenderNode {
type: string;
version: string;
component: Type<unknown>;
section: SectionConfig;
data: unknown;
}

View File

@@ -1,23 +1,77 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { WidgetConfig } from '../../shared/models/config'; import { Observable, map, shareReplay } from 'rxjs';
import { WidgetRegistryService } from '../../widgets/registry/widget-registry.service'; 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 { 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' }) @Injectable({ providedIn: 'root' })
export class WidgetHostService { export class WidgetHostService {
constructor(private readonly registry: WidgetRegistryService) {} private readonly resolvedWidgets = new Map<string, Observable<ResolvedWidget>>();
private readonly loggedUnknownWidgets = new Set<string>();
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<ResolvedWidget> {
const cacheKey = `${pageId}:${section.id}:${widget.id}`;
const cached = this.resolvedWidgets.get(cacheKey);
if (cached) {
return cached;
}
resolveWidget(widget: WidgetConfig): ResolvedWidget | null {
const registered = this.registry.resolve(widget.type, widget.version); const registered = this.registry.resolve(widget.type, widget.version);
const resolved$ = this.dataSourceResolver.resolve(widget, section).pipe(
map((data) => {
if (!registered) { if (!registered) {
return null; this.logUnknownWidget(pageId, section.id, widget);
return {
type: widget.type,
version: widget.version,
component: UnknownWidgetComponent,
section,
data
};
} }
return { return {
type: registered.type, type: registered.type,
version: registered.version, version: registered.version,
component: registered.component, component: registered.component,
props: widget.props ?? {} 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'
});
} }
} }

View File

@@ -1,12 +1,11 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { NgComponentOutlet } from '@angular/common'; import { NgComponentOutlet } from '@angular/common';
import { Observable } from 'rxjs';
import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model'; import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model';
import { WidgetRenderNode } from '../../dynamic-renderer/widget-host/widget-host.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 { 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({ @Component({
selector: 'app-dynamic-page-layout', selector: 'app-dynamic-page-layout',
@@ -29,8 +28,8 @@ import { ConfigService } from '../../core/config/config.service';
> >
@for (widget of section.widgets; track widget.id) { @for (widget of section.widgets; track widget.id) {
<div class="dynamic-widget" [attr.data-widget-type]="widget.type"> <div class="dynamic-widget" [attr.data-widget-type]="widget.type">
@if (resolveWidget(widget, section.id); as resolved) { @if (resolveWidget(widget, section, model.id) | async; as resolved) {
<ng-container *ngComponentOutlet="resolved.component; inputs: resolved.props"></ng-container> <ng-container *ngComponentOutlet="$any(resolved).component; inputs: { section: $any(resolved).section, data: $any(resolved).data }"></ng-container>
} }
</div> </div>
} }
@@ -71,42 +70,10 @@ import { ConfigService } from '../../core/config/config.service';
}) })
export class DynamicPageLayoutComponent { export class DynamicPageLayoutComponent {
@Input() model: PageRenderModel | null = null; @Input() model: PageRenderModel | null = null;
private readonly loggedUnknownWidgets = new Set<string>();
constructor( constructor(private readonly widgetHost: WidgetHostService) {}
private readonly widgetHost: WidgetHostService,
private readonly diagnostics: RuntimeDiagnosticsService,
private readonly configService: ConfigService
) {}
resolveWidget(widget: WidgetRenderNode, sectionId?: string) { resolveWidget(widget: WidgetRenderNode, section: PageRenderModel['sections'][number], pageId: string): Observable<ResolvedWidget> {
const resolved = this.widgetHost.resolveWidget(widget.source); return this.widgetHost.resolveWidget(widget.source, section.source, pageId);
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
}
};
} }
} }

View File

@@ -1,139 +1,7 @@
<!-- novo VERSION - Modern Grid Layout -->
@if (isMarketplaceNovo) {
<div class="novo-home">
@if (pageModel()) {
<app-dynamic-page-layout [model]="pageModel()" />
}
@if (loading()) { @if (loading()) {
<section class="novo-categories"> <section class="home-loading">Loading homepage...</section>
<div class="novo-section-header"> } @else if (pageModel()) {
<div class="skeleton-line" style="height: 32px; width: 200px; margin: 0 auto 12px;"></div>
<div class="skeleton-line" style="height: 18px; width: 300px; margin: 0 auto;"></div>
</div>
<div class="novo-categories-grid">
@for (i of skeletonSlots; track i) {
<div class="novo-category-card skeleton-card">
<div class="novo-category-image skeleton-image"></div>
<div class="novo-category-info">
<div class="skeleton-line" style="height: 18px; width: 70%;"></div>
<div class="skeleton-line" style="height: 18px; width: 20px;"></div>
</div>
</div>
}
</div>
</section>
}
@if (error()) {
<div class="novo-error">
<div class="novo-error-icon">⚠️</div>
<h3>{{ 'home.errorTitle' | translate }}</h3>
<p>{{ error() }}</p>
<button (click)="loadCategories()" class="novo-retry-btn">{{ 'home.retry' | translate }}</button>
</div>
}
@if (!loading() && !error()) {
<section class="novo-categories">
<div class="novo-section-header">
<h2>{{ 'home.categoriesTitle' | translate }}</h2>
<p>{{ 'home.categoriesSubtitle' | translate }}</p>
</div>
@if (topLevelCategories().length === 0) {
<div class="novo-empty">
<div class="novo-empty-icon">📦</div>
<h3>{{ 'home.categoriesEmpty' | translate }}</h3>
<p>{{ 'home.categoriesEmptyDesc' | translate }}</p>
</div>
} @else {
<div class="novo-categories-grid">
@for (category of topLevelCategories(); track category.id) {
<a [routerLink]="['/catalog', category.id] | langRoute" class="novo-category-card">
<div class="novo-category-image">
@if (category.icon) {
<img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" />
} @else {
<div class="novo-category-placeholder">
<span>{{ categoryName(category).charAt(0) }}</span>
</div>
}
</div>
<div class="novo-category-info">
<h3>{{ categoryName(category) }}</h3>
@if (getItemCount(category.id)) {
<p class="novo-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.id) } }}</p>
}
</div>
</a>
}
</div>
}
</section>
}
</div>
} @else {
<!-- DEXAR VERSION - Redesigned 2026 -->
<div class="dexar-home">
@if (pageModel()) {
<app-dynamic-page-layout [model]="pageModel()" /> <app-dynamic-page-layout [model]="pageModel()" />
}
@if (loading()) {
<section class="dexar-categories">
<div class="skeleton-line" style="height: 36px; width: 220px; margin-bottom: 40px;"></div>
<div class="dexar-categories-grid">
@for (i of skeletonSlots; track i) {
<div class="dexar-category-card skeleton-card">
<div class="dexar-category-image skeleton-image"></div>
<div class="dexar-category-info">
<div class="skeleton-line" style="height: 16px; width: 75%;"></div>
<div class="skeleton-line" style="height: 12px; width: 40%; margin-top: 4px;"></div>
</div>
</div>
}
</div>
</section>
}
@if (error()) {
<div class="dexar-error">
<p>{{ error() }}</p>
<button (click)="loadCategories()" class="dexar-retry-btn">{{ 'home.retry' | translate }}</button>
</div>
}
@if (!loading() && !error()) {
<section class="dexar-categories" id="catalog">
<h2 class="dexar-categories-title">{{ 'home.catalogTitle' | translate }}</h2>
@if (topLevelCategories().length === 0) {
<div class="dexar-empty-categories">
<div class="dexar-empty-icon">📦</div>
<h3>{{ 'home.emptyCategoriesDexar' | translate }}</h3>
<p>{{ 'home.categoriesSoonDexar' | translate }}</p>
</div>
} @else { } @else {
<div class="dexar-categories-grid"> <section class="home-empty">No page configuration found.</section>
@for (category of topLevelCategories(); track category.id) {
<a [routerLink]="['/catalog', category.id] | langRoute"
class="dexar-category-card">
<div class="dexar-category-image">
@if (category.icon) {
<img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" decoding="async" />
} @else {
<div class="dexar-category-fallback">{{ categoryName(category).charAt(0) }}</div>
}
</div>
<div class="dexar-category-info">
<h3 class="dexar-category-name">{{ categoryName(category) }}</h3>
<p class="dexar-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.id) } }}</p>
</div>
</a>
}
</div>
}
</section>
}
</div>
} }

View File

@@ -1,156 +1,41 @@
import { Component, OnInit, signal, computed, ChangeDetectionStrategy } from '@angular/core'; import { Component, OnInit, signal, ChangeDetectionStrategy } from '@angular/core';
import { Router, RouterLink } from '@angular/router'; import { Router } from '@angular/router';
import { LanguageService } from '../../services';
import { DynamicPageLayoutComponent } from '../../layouts/containers/dynamic-page-layout.component'; import { DynamicPageLayoutComponent } from '../../layouts/containers/dynamic-page-layout.component';
import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model'; import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model';
import { WebsiteRuntimeFacade } from '../../facades/website/website-runtime.facade'; 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({ @Component({
selector: 'app-home', selector: 'app-home',
standalone: true, standalone: true,
imports: [RouterLink, DynamicPageLayoutComponent, LangRoutePipe, TranslatePipe], imports: [DynamicPageLayoutComponent],
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'], styleUrls: ['./home.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class HomeComponent implements OnInit { export class HomeComponent implements OnInit {
constructor( constructor(
private router: Router, private readonly router: Router,
private langService: LanguageService,
private readonly uiRuntime: UiRuntimeFacade,
private readonly categoryFacade: CategoryFacade,
private readonly websiteRuntime: WebsiteRuntimeFacade private readonly websiteRuntime: WebsiteRuntimeFacade
) {} ) {}
categories = signal<Category[]>([]);
loading = signal(true); loading = signal(true);
error = signal<string | null>(null); error = signal<string | null>(null);
readonly pageModel = signal<PageRenderModel | null>(null); readonly pageModel = signal<PageRenderModel | null>(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<number, number>();
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<number, Category[]>();
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 { ngOnInit(): void {
this.loadHomepageSections(); this.loadHomepageSections();
this.loadCategories();
} }
private loadHomepageSections(): void { private loadHomepageSections(): void {
this.websiteRuntime.getPageRenderModelForUrl(this.router.url).subscribe({ this.websiteRuntime.getPageRenderModelForUrl(this.router.url).subscribe({
next: (model) => { next: (model) => {
this.pageModel.set(model); this.pageModel.set(model);
this.loading.set(false);
}, },
error: () => { error: () => {
this.pageModel.set(null); 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);
}
} }

View File

@@ -1,4 +1,5 @@
import { Type } from '@angular/core'; import { Type } from '@angular/core';
import { SectionConfig } from '../../shared/models/config';
export interface WidgetRenderContext { export interface WidgetRenderContext {
pageId: string; pageId: string;
@@ -16,5 +17,6 @@ export interface ResolvedWidget {
type: string; type: string;
version: string; version: string;
component: Type<unknown>; component: Type<unknown>;
props: Record<string, unknown>; section: SectionConfig;
data: unknown;
} }

View File

@@ -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<string, unknown>;
}
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 }>;
}

View File

@@ -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<string, unknown>;
required?: string[];
}
export interface WidgetManifestEntry {
type: string;
version: string;
componentKey: string;
supportedLayouts: WidgetLayoutSupport[];
supportedDataSources: WidgetDataSourceName[];
settingsSchema: WidgetSettingsSchema;
defaultSettings: Record<string, unknown>;
enabled?: boolean;
}
export interface WidgetManifestFile {
widgets: WidgetManifestEntry[];
}

View File

@@ -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<WidgetManifestFile>;
constructor(private readonly http: HttpClient) {}
getManifest(): Observable<WidgetManifestFile> {
if (!this.manifest$) {
this.manifest$ = this.http.get<WidgetManifestFile>(this.manifestUrl).pipe(
shareReplay({ bufferSize: 1, refCount: true }),
catchError(() => {
this.manifest$ = undefined;
return of({ widgets: [] });
})
);
}
return this.manifest$;
}
getWidgets(): Observable<WidgetManifestEntry[]> {
return this.getManifest().pipe(map((manifest) => manifest.widgets ?? []));
}
getWidget(type: string): Observable<WidgetManifestEntry | undefined> {
return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type)));
}
}

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable, map, of } from 'rxjs'; import { Observable, map, of } from 'rxjs';
import { catchError } from 'rxjs/operators'; 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 { RegisteredWidget } from '../contracts/widget-component.contract';
import { WidgetRegistryService } from './widget-registry.service'; import { WidgetRegistryService } from './widget-registry.service';
@@ -19,8 +19,11 @@ interface WidgetManifest {
const APPROVED_WIDGET_COMPONENTS: Record<string, RegisteredWidget['component']> = { const APPROVED_WIDGET_COMPONENTS: Record<string, RegisteredWidget['component']> = {
'hero': HeroWidgetComponent, 'hero': HeroWidgetComponent,
'categories': CategoriesWidgetComponent,
'footer': FooterNavigationWidgetComponent,
'footer-navigation': FooterNavigationWidgetComponent, 'footer-navigation': FooterNavigationWidgetComponent,
'product-carousel': ProductCarouselWidgetComponent 'product-carousel': ProductCarouselWidgetComponent,
'product-collection': ProductCarouselWidgetComponent
}; };
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })

View File

@@ -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<unknown> {
return this.widgetManifest.getWidget(widget.type).pipe(
switchMap((definition) => this.resolveByDefinition(definition, widget, section))
);
}
private resolveByDefinition(definition: WidgetManifestEntry | undefined, widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
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<string, unknown> {
return {
...(definition?.defaultSettings ?? {}),
...(widget.props ?? {})
};
}
private resolveSource(widget: WidgetConfig, settings: Record<string, unknown>, 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<string, unknown>): 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<string, unknown>, source?: WidgetDataSourceName): Observable<CategoriesWidgetData> {
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<string, unknown>, source?: WidgetDataSourceName): Observable<ProductCollectionWidgetData> {
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<string, unknown>, title: string, products: Product[], actionLabel?: string, emptyMessage?: string): ProductCollectionWidgetData {
return {
section,
settings,
title,
products,
actionLabel,
emptyMessage
};
}
private toBannerData(section: SectionConfig, settings: Record<string, unknown>): 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<string, unknown>): HtmlWidgetData {
return {
section,
settings,
html: String(settings['html'] ?? '')
};
}
private toPartnersData(section: SectionConfig, settings: Record<string, unknown>): 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<string, unknown>): 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;
}
}

View File

@@ -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: `
<section class="categories-widget">
@if (data; as widgetData) {
@if (widgetData.title) {
<h2>{{ widgetData.title }}</h2>
}
@if (widgetData.categories.length) {
<app-catalog-category-grid [categories]="widgetData.categories" />
} @else if (widgetData.emptyMessage) {
<p class="categories-widget__empty">{{ widgetData.emptyMessage }}</p>
}
}
</section>
`,
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;
}

View File

@@ -1,5 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; 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 { export interface FooterNavigationLink {
id: string; id: string;
@@ -15,7 +17,7 @@ export interface FooterNavigationLink {
<footer class="footer-nav-widget"> <footer class="footer-nav-widget">
<nav> <nav>
<ul> <ul>
@for (link of links; track link.id) { @for (link of data?.links ?? []; track link.id) {
<li> <li>
<button type="button" (click)="onLinkClick(link)">{{ link.label }}</button> <button type="button" (click)="onLinkClick(link)">{{ link.label }}</button>
</li> </li>
@@ -34,7 +36,8 @@ export interface FooterNavigationLink {
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FooterNavigationWidgetComponent { export class FooterNavigationWidgetComponent {
@Input() links: FooterNavigationLink[] = []; @Input() section: SectionConfig | null = null;
@Input() data: FooterWidgetData | null = null;
@Output() linkSelected = new EventEmitter<FooterNavigationLink>(); @Output() linkSelected = new EventEmitter<FooterNavigationLink>();

View File

@@ -1,5 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { SectionConfig } from '../../shared/models/config';
import { HeroWidgetData } from '../contracts/widget-data.contract';
@Component({ @Component({
selector: 'app-hero-widget', selector: 'app-hero-widget',
@@ -7,12 +9,14 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
imports: [CommonModule], imports: [CommonModule],
template: ` template: `
<section class="hero-widget"> <section class="hero-widget">
<h1 class="hero-widget__title">{{ title }}</h1> @if (data; as widgetData) {
@if (subtitle) { <h1 class="hero-widget__title">{{ widgetData.title }}</h1>
<p class="hero-widget__subtitle">{{ subtitle }}</p> @if (widgetData.subtitle) {
<p class="hero-widget__subtitle">{{ widgetData.subtitle }}</p>
}
@if (widgetData.ctaLabel) {
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ widgetData.ctaLabel }}</button>
} }
@if (ctaLabel) {
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ ctaLabel }}</button>
} }
</section> </section>
`, `,
@@ -27,9 +31,8 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class HeroWidgetComponent { export class HeroWidgetComponent {
@Input() title: string = ''; @Input() section: SectionConfig | null = null;
@Input() subtitle: string = ''; @Input() data: HeroWidgetData | null = null;
@Input() ctaLabel: string = '';
@Output() ctaClicked = new EventEmitter<void>(); @Output() ctaClicked = new EventEmitter<void>();

View File

@@ -1,3 +1,4 @@
export * from './categories-widget.component';
export * from './footer-navigation-widget.component'; export * from './footer-navigation-widget.component';
export * from './hero-widget.component'; export * from './hero-widget.component';
export * from './product-carousel-widget.component'; export * from './product-carousel-widget.component';

View File

@@ -1,53 +1,37 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ProductCardConfig } from '../../shared/models/ui'; import { SectionConfig } from '../../shared/models/config';
import { CatalogProductGridComponent } from '../../features/website/catalog/components/product-grid/product-grid.component';
import { ProductCollectionWidgetData } from '../contracts/widget-data.contract';
@Component({ @Component({
selector: 'app-product-carousel-widget', selector: 'app-product-carousel-widget',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, CatalogProductGridComponent],
template: ` template: `
<section class="product-carousel-widget"> <section class="product-carousel-widget">
@if (title) { @if (data?.title) {
<h2>{{ title }}</h2> <h2>{{ data?.title }}</h2>
} }
<div class="product-carousel-widget__grid"> @if (data?.products?.length) {
@for (item of items; track item.id) { <app-catalog-product-grid [products]="data?.products ?? []" />
<article class="product-card"> } @else if (data?.emptyMessage) {
<img [src]="item.imageUrl" [alt]="item.title" loading="lazy" /> <p class="product-carousel-widget__empty">{{ data?.emptyMessage }}</p>
<h3>{{ item.title }}</h3>
@if (item.subtitle) {
<p>{{ item.subtitle }}</p>
} }
<strong>{{ item.price.amount }} {{ item.price.currency }}</strong>
<button type="button" (click)="onProductClick(item)">{{ actionLabel }}</button>
</article>
}
</div>
</section> </section>
`, `,
styles: [ styles: [
` `
.product-carousel-widget { padding: 1rem; } .product-carousel-widget { padding: 1rem; }
.product-carousel-widget__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; } .product-carousel-widget__empty { margin: 0; color: var(--text-secondary, #667a77); }
.product-card { padding: 0.75rem; border: 1px solid var(--border-color, #d3dad9); border-radius: var(--radius-md, 12px); }
img { width: 100%; height: 140px; object-fit: cover; border-radius: 8px; margin-bottom: 0.5rem; }
h3 { margin: 0 0 0.25rem; font-size: 1rem; }
p { margin: 0 0 0.5rem; color: var(--text-secondary, #667a77); }
button { margin-top: 0.5rem; border: none; border-radius: 8px; padding: 0.5rem 0.75rem; cursor: pointer; background: var(--primary-color, #497671); color: #fff; }
` `
], ],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ProductCarouselWidgetComponent { export class ProductCarouselWidgetComponent {
@Input() title: string = ''; @Input() section: SectionConfig | null = null;
@Input() actionLabel: string = 'Select'; @Input() data: ProductCollectionWidgetData | null = null;
@Input() items: ProductCardConfig[] = [];
@Output() productSelected = new EventEmitter<ProductCardConfig>(); @Output() productSelected = new EventEmitter<unknown>();
onProductClick(item: ProductCardConfig): void {
this.productSelected.emit(item);
}
} }

View File

@@ -221,7 +221,7 @@
"key": "home", "key": "home",
"title": "Home", "title": "Home",
"route": { "route": {
"path": "/platform", "path": "/",
"exact": true "exact": true
}, },
"layout": "default-public", "layout": "default-public",
@@ -232,6 +232,17 @@
"id": "section-hero", "id": "section-hero",
"type": "hero", "type": "hero",
"order": 1, "order": 1,
"layout": {
"strategy": "hero",
"columns": 1,
"gap": "1.5rem",
"align": "stretch"
},
"visibility": {
"desktop": true,
"tablet": true,
"mobile": true
},
"visible": true, "visible": true,
"widgets": [ "widgets": [
{ {
@@ -246,6 +257,67 @@
} }
} }
] ]
},
{
"id": "section-categories",
"type": "categories",
"order": 2,
"layout": {
"strategy": "grid",
"columns": 1,
"gap": "1.5rem",
"align": "stretch"
},
"visibility": {
"desktop": true,
"tablet": true,
"mobile": true
},
"visible": true,
"widgets": [
{
"id": "widget-categories-root",
"type": "categories",
"version": "1.0.0",
"visible": true,
"props": {
"title": "Categories",
"source": "root",
"emptyMessage": "No categories available"
}
}
]
},
{
"id": "section-featured-products",
"type": "product-collection",
"order": 3,
"layout": {
"strategy": "carousel",
"columns": 1,
"gap": "1rem",
"align": "stretch"
},
"visibility": {
"desktop": true,
"tablet": true,
"mobile": true
},
"visible": true,
"widgets": [
{
"id": "widget-featured-products",
"type": "product-collection",
"version": "1.0.0",
"visible": true,
"props": {
"title": "Featured Products",
"source": "featured",
"count": 8,
"actionLabel": "Select"
}
}
]
} }
] ]
} }

View File

@@ -41,9 +41,39 @@
] ]
}, },
{ {
"id": "section-featured-products", "id": "section-categories",
"type": "content-grid", "type": "categories",
"order": 2, "order": 2,
"layout": {
"strategy": "grid",
"columns": 1,
"gap": "1.5rem",
"align": "stretch"
},
"visibility": {
"desktop": true,
"tablet": true,
"mobile": true
},
"visible": true,
"widgets": [
{
"id": "widget-categories-root",
"type": "categories",
"version": "1.0.0",
"visible": true,
"props": {
"title": "Categories",
"source": "root",
"emptyMessage": "No categories available"
}
}
]
},
{
"id": "section-featured-products",
"type": "product-collection",
"order": 3,
"layout": { "layout": {
"strategy": "carousel", "strategy": "carousel",
"columns": 1, "columns": 1,
@@ -58,13 +88,15 @@
"visible": true, "visible": true,
"widgets": [ "widgets": [
{ {
"id": "widget-featured-carousel", "id": "widget-featured-products",
"type": "product-carousel", "type": "product-collection",
"version": "1.0.0", "version": "1.0.0",
"visible": true, "visible": true,
"props": { "props": {
"title": "Featured Products", "title": "Featured Products",
"source": "products.featured" "source": "featured",
"count": 8,
"actionLabel": "Select"
} }
} }
] ]

View File

@@ -4,19 +4,180 @@
"type": "hero", "type": "hero",
"version": "1.0.0", "version": "1.0.0",
"componentKey": "hero", "componentKey": "hero",
"supportedLayouts": ["hero", "split"],
"supportedDataSources": ["future"],
"settingsSchema": {
"type": "object",
"properties": {
"title": { "type": "string" },
"subtitle": { "type": "string" },
"ctaLabel": { "type": "string" }
}
},
"defaultSettings": {
"title": "Welcome to Marketplace Platform",
"subtitle": "Configuration-driven multi-tenant commerce",
"ctaLabel": "Start Shopping"
},
"enabled": true
},
{
"type": "categories",
"version": "1.0.0",
"componentKey": "categories",
"supportedLayouts": ["grid"],
"supportedDataSources": ["root", "parent", "manual", "future"],
"settingsSchema": {
"type": "object",
"properties": {
"title": { "type": "string" },
"source": { "type": "string" },
"parentId": { "type": "number" },
"emptyMessage": { "type": "string" }
}
},
"defaultSettings": {
"title": "Categories",
"source": "root",
"emptyMessage": "No categories available"
},
"enabled": true
},
{
"type": "product-collection",
"version": "1.0.0",
"componentKey": "product-collection",
"supportedLayouts": ["carousel", "grid"],
"supportedDataSources": ["featured", "latest", "category", "manual", "related", "future"],
"settingsSchema": {
"type": "object",
"properties": {
"title": { "type": "string" },
"source": { "type": "string" },
"count": { "type": "number" },
"categoryId": { "type": "number" },
"productId": { "type": "number" },
"actionLabel": { "type": "string" },
"emptyMessage": { "type": "string" }
}
},
"defaultSettings": {
"title": "Featured Products",
"source": "featured",
"count": 8,
"actionLabel": "Select"
},
"enabled": true
},
{
"type": "product-carousel",
"version": "1.0.0",
"componentKey": "product-collection",
"supportedLayouts": ["carousel", "grid"],
"supportedDataSources": ["featured", "latest", "category", "manual", "related", "future"],
"settingsSchema": {
"type": "object",
"properties": {
"title": { "type": "string" },
"source": { "type": "string" },
"count": { "type": "number" }
}
},
"defaultSettings": {
"title": "Featured Products",
"source": "featured",
"count": 8
},
"enabled": true
},
{
"type": "footer",
"version": "1.0.0",
"componentKey": "footer-navigation",
"supportedLayouts": ["stack"],
"supportedDataSources": ["manual", "future"],
"settingsSchema": {
"type": "object",
"properties": {
"links": { "type": "array" },
"title": { "type": "string" }
}
},
"defaultSettings": {
"links": []
},
"enabled": true "enabled": true
}, },
{ {
"type": "footer-navigation", "type": "footer-navigation",
"version": "1.0.0", "version": "1.0.0",
"componentKey": "footer-navigation", "componentKey": "footer-navigation",
"supportedLayouts": ["stack"],
"supportedDataSources": ["manual", "future"],
"settingsSchema": {
"type": "object",
"properties": {
"links": { "type": "array" }
}
},
"defaultSettings": {
"links": []
},
"enabled": true "enabled": true
}, },
{ {
"type": "product-carousel", "type": "banner",
"version": "1.0.0", "version": "1.0.0",
"componentKey": "product-carousel", "componentKey": "banner",
"enabled": true "supportedLayouts": ["hero", "split", "stack"],
"supportedDataSources": ["future"],
"settingsSchema": {
"type": "object",
"properties": {
"title": { "type": "string" },
"subtitle": { "type": "string" },
"imageUrl": { "type": "string" },
"ctaLabel": { "type": "string" },
"ctaHref": { "type": "string" }
}
},
"defaultSettings": {},
"enabled": false
},
{
"type": "html",
"version": "1.0.0",
"componentKey": "html",
"supportedLayouts": ["stack"],
"supportedDataSources": ["future"],
"settingsSchema": {
"type": "object",
"properties": {
"html": { "type": "string" }
}
},
"defaultSettings": {
"html": ""
},
"enabled": false
},
{
"type": "partners",
"version": "1.0.0",
"componentKey": "partners",
"supportedLayouts": ["stack", "grid"],
"supportedDataSources": ["future"],
"settingsSchema": {
"type": "object",
"properties": {
"title": { "type": "string" },
"logos": { "type": "array" }
}
},
"defaultSettings": {
"logos": []
},
"enabled": false
} }
] ]
} }