Sprint 8: add widget manifest and data source engine
This commit is contained in:
91
docs/Widget-Manifest-Report.md
Normal file
91
docs/Widget-Manifest-Report.md
Normal 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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/app/widgets/contracts/widget-data.contract.ts
Normal file
48
src/app/widgets/contracts/widget-data.contract.ts
Normal 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 }>;
|
||||||
|
}
|
||||||
24
src/app/widgets/contracts/widget-manifest.contract.ts
Normal file
24
src/app/widgets/contracts/widget-manifest.contract.ts
Normal 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[];
|
||||||
|
}
|
||||||
34
src/app/widgets/registry/widget-manifest.service.ts
Normal file
34
src/app/widgets/registry/widget-manifest.service.ts
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
|||||||
213
src/app/widgets/resolvers/data-source-resolver.service.ts
Normal file
213
src/app/widgets/resolvers/data-source-resolver.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/widgets/ui/categories-widget.component.ts
Normal file
37
src/app/widgets/ui/categories-widget.component.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user