Sprint 8: add widget manifest and data source engine
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Type } from '@angular/core';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
|
||||
export interface WidgetRenderContext {
|
||||
pageId: string;
|
||||
@@ -16,5 +17,6 @@ export interface ResolvedWidget {
|
||||
type: string;
|
||||
version: string;
|
||||
component: Type<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 { Observable, map, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { HeroWidgetComponent, FooterNavigationWidgetComponent, ProductCarouselWidgetComponent } from '../ui';
|
||||
import { CategoriesWidgetComponent, FooterNavigationWidgetComponent, HeroWidgetComponent, ProductCarouselWidgetComponent } from '../ui';
|
||||
import { RegisteredWidget } from '../contracts/widget-component.contract';
|
||||
import { WidgetRegistryService } from './widget-registry.service';
|
||||
|
||||
@@ -19,8 +19,11 @@ interface WidgetManifest {
|
||||
|
||||
const APPROVED_WIDGET_COMPONENTS: Record<string, RegisteredWidget['component']> = {
|
||||
'hero': HeroWidgetComponent,
|
||||
'categories': CategoriesWidgetComponent,
|
||||
'footer': FooterNavigationWidgetComponent,
|
||||
'footer-navigation': FooterNavigationWidgetComponent,
|
||||
'product-carousel': ProductCarouselWidgetComponent
|
||||
'product-carousel': ProductCarouselWidgetComponent,
|
||||
'product-collection': ProductCarouselWidgetComponent
|
||||
};
|
||||
|
||||
@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 { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { FooterWidgetData } from '../contracts/widget-data.contract';
|
||||
|
||||
export interface FooterNavigationLink {
|
||||
id: string;
|
||||
@@ -15,7 +17,7 @@ export interface FooterNavigationLink {
|
||||
<footer class="footer-nav-widget">
|
||||
<nav>
|
||||
<ul>
|
||||
@for (link of links; track link.id) {
|
||||
@for (link of data?.links ?? []; track link.id) {
|
||||
<li>
|
||||
<button type="button" (click)="onLinkClick(link)">{{ link.label }}</button>
|
||||
</li>
|
||||
@@ -34,7 +36,8 @@ export interface FooterNavigationLink {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FooterNavigationWidgetComponent {
|
||||
@Input() links: FooterNavigationLink[] = [];
|
||||
@Input() section: SectionConfig | null = null;
|
||||
@Input() data: FooterWidgetData | null = null;
|
||||
|
||||
@Output() linkSelected = new EventEmitter<FooterNavigationLink>();
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { HeroWidgetData } from '../contracts/widget-data.contract';
|
||||
|
||||
@Component({
|
||||
selector: 'app-hero-widget',
|
||||
@@ -7,12 +9,14 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="hero-widget">
|
||||
<h1 class="hero-widget__title">{{ title }}</h1>
|
||||
@if (subtitle) {
|
||||
<p class="hero-widget__subtitle">{{ subtitle }}</p>
|
||||
}
|
||||
@if (ctaLabel) {
|
||||
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ ctaLabel }}</button>
|
||||
@if (data; as widgetData) {
|
||||
<h1 class="hero-widget__title">{{ widgetData.title }}</h1>
|
||||
@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>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
@@ -27,9 +31,8 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class HeroWidgetComponent {
|
||||
@Input() title: string = '';
|
||||
@Input() subtitle: string = '';
|
||||
@Input() ctaLabel: string = '';
|
||||
@Input() section: SectionConfig | null = null;
|
||||
@Input() data: HeroWidgetData | null = null;
|
||||
|
||||
@Output() ctaClicked = new EventEmitter<void>();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './categories-widget.component';
|
||||
export * from './footer-navigation-widget.component';
|
||||
export * from './hero-widget.component';
|
||||
export * from './product-carousel-widget.component';
|
||||
|
||||
@@ -1,53 +1,37 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
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({
|
||||
selector: 'app-product-carousel-widget',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, CatalogProductGridComponent],
|
||||
template: `
|
||||
<section class="product-carousel-widget">
|
||||
@if (title) {
|
||||
<h2>{{ title }}</h2>
|
||||
@if (data?.title) {
|
||||
<h2>{{ data?.title }}</h2>
|
||||
}
|
||||
|
||||
<div class="product-carousel-widget__grid">
|
||||
@for (item of items; track item.id) {
|
||||
<article class="product-card">
|
||||
<img [src]="item.imageUrl" [alt]="item.title" loading="lazy" />
|
||||
<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>
|
||||
@if (data?.products?.length) {
|
||||
<app-catalog-product-grid [products]="data?.products ?? []" />
|
||||
} @else if (data?.emptyMessage) {
|
||||
<p class="product-carousel-widget__empty">{{ data?.emptyMessage }}</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.product-carousel-widget { padding: 1rem; }
|
||||
.product-carousel-widget__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
|
||||
.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; }
|
||||
.product-carousel-widget__empty { margin: 0; color: var(--text-secondary, #667a77); }
|
||||
`
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProductCarouselWidgetComponent {
|
||||
@Input() title: string = '';
|
||||
@Input() actionLabel: string = 'Select';
|
||||
@Input() items: ProductCardConfig[] = [];
|
||||
@Input() section: SectionConfig | null = null;
|
||||
@Input() data: ProductCollectionWidgetData | null = null;
|
||||
|
||||
@Output() productSelected = new EventEmitter<ProductCardConfig>();
|
||||
|
||||
onProductClick(item: ProductCardConfig): void {
|
||||
this.productSelected.emit(item);
|
||||
}
|
||||
@Output() productSelected = new EventEmitter<unknown>();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user