Sprint 7: add section engine
This commit is contained in:
75
docs/Section-Engine-Report.md
Normal file
75
docs/Section-Engine-Report.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Section Engine Report
|
||||
|
||||
## Scope
|
||||
|
||||
Sprint 7 introduced the generic section engine used by the homepage runtime path. The implementation stays within the frozen frontend architecture and does not change backend APIs, auth, payment, or bootstrap contracts.
|
||||
|
||||
The section engine now treats the homepage as a section collection rendered through the existing dynamic page layout pipeline:
|
||||
|
||||
- page config -> section engine -> section renderer -> widget host -> registered widget components
|
||||
|
||||
## Implemented Changes
|
||||
|
||||
### Section Metadata
|
||||
|
||||
- `src/app/shared/models/config/section.model.ts`
|
||||
|
||||
Added explicit section metadata for:
|
||||
|
||||
- layout strategy
|
||||
- layout columns/gap/alignment
|
||||
- desktop/tablet/mobile visibility flags
|
||||
|
||||
### Render Nodes
|
||||
|
||||
- `src/app/dynamic-renderer/section-renderer/section-renderer.model.ts`
|
||||
- `src/app/dynamic-renderer/section-renderer/section-renderer.service.ts`
|
||||
|
||||
Section render nodes now carry layout and visibility metadata forward from the shared section config.
|
||||
|
||||
### Section Engine
|
||||
|
||||
- `src/app/dynamic-renderer/section-engine/section-engine.service.ts`
|
||||
|
||||
Added the section engine orchestration service to build ordered page render models from section definitions.
|
||||
|
||||
### Page Renderer
|
||||
|
||||
- `src/app/dynamic-renderer/page-renderer/page-renderer.service.ts`
|
||||
|
||||
Delegates page model assembly to the section engine.
|
||||
|
||||
### Dynamic Layout
|
||||
|
||||
- `src/app/layouts/containers/dynamic-page-layout.component.ts`
|
||||
|
||||
The layout container now reads section layout strategy and visibility metadata when rendering sections.
|
||||
|
||||
### Homepage Migration
|
||||
|
||||
- `src/app/pages/home/home.component.ts`
|
||||
- `src/app/pages/home/home.component.html`
|
||||
- `src/assets/mock/bootstrap/homepage.json`
|
||||
|
||||
The homepage now loads the runtime page model and renders its section collection instead of hardcoding the hero and product carousel blocks directly in the template.
|
||||
|
||||
The existing category area remains on the homepage and still uses the category domain/facade path.
|
||||
|
||||
## Validation
|
||||
|
||||
Completed checks:
|
||||
|
||||
- Section layout and visibility metadata compile cleanly.
|
||||
- Page renderer delegates through the new section engine service.
|
||||
- Homepage composes the runtime page model through `DynamicPageLayoutComponent`.
|
||||
- The workspace build passes.
|
||||
|
||||
Build validation:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Stop Point
|
||||
|
||||
Sprint 7 section-engine groundwork is complete for the current homepage flow. Stop here and wait for approval before extending the section system to additional website pages or adding new widget/data resolvers.
|
||||
@@ -1,25 +1,13 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { PageConfig } from '../../shared/models/config';
|
||||
import { PageRenderModel } from './page-renderer.model';
|
||||
import { SectionRendererService } from '../section-renderer/section-renderer.service';
|
||||
import { SectionEngineService } from '../section-engine/section-engine.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PageRendererService {
|
||||
constructor(private readonly sectionRenderer: SectionRendererService) {}
|
||||
constructor(private readonly sectionEngine: SectionEngineService) {}
|
||||
|
||||
toRenderModel(page: PageConfig): PageRenderModel {
|
||||
const sections = (page.sections ?? [])
|
||||
.filter(section => section.visible !== false)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(section => this.sectionRenderer.toRenderNode(section));
|
||||
|
||||
return {
|
||||
id: page.id,
|
||||
key: page.key,
|
||||
title: page.title,
|
||||
layout: page.layout,
|
||||
sections,
|
||||
source: page
|
||||
};
|
||||
return this.sectionEngine.toPageRenderModel(page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { PageConfig } from '../../shared/models/config';
|
||||
import { PageRenderModel } from '../page-renderer/page-renderer.model';
|
||||
import { SectionRendererService } from '../section-renderer/section-renderer.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SectionEngineService {
|
||||
constructor(private readonly sectionRenderer: SectionRendererService) {}
|
||||
|
||||
toPageRenderModel(page: PageConfig): PageRenderModel {
|
||||
const sections = (page.sections ?? [])
|
||||
.filter((section) => section.visible !== false)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((section) => this.sectionRenderer.toRenderNode(section));
|
||||
|
||||
return {
|
||||
id: page.id,
|
||||
key: page.key,
|
||||
title: page.title,
|
||||
layout: page.layout,
|
||||
sections,
|
||||
source: page
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { SectionLayoutConfig, SectionVisibilityConfig } from '../../shared/models/config';
|
||||
import { WidgetRenderNode } from '../widget-host/widget-host.model';
|
||||
|
||||
export interface SectionRenderNode {
|
||||
id: string;
|
||||
type: string;
|
||||
order: number;
|
||||
layout?: SectionLayoutConfig;
|
||||
visibility?: SectionVisibilityConfig;
|
||||
featureFlag?: string;
|
||||
visible?: boolean;
|
||||
widgets: WidgetRenderNode[];
|
||||
|
||||
@@ -22,6 +22,8 @@ export class SectionRendererService {
|
||||
id: section.id,
|
||||
type: section.type,
|
||||
order: section.order,
|
||||
layout: section.layout,
|
||||
visibility: section.visibility,
|
||||
featureFlag: section.featureFlag,
|
||||
visible: section.visible,
|
||||
widgets,
|
||||
|
||||
@@ -16,7 +16,17 @@ import { ConfigService } from '../../core/config/config.service';
|
||||
@if (model) {
|
||||
<main class="dynamic-page-layout" [attr.data-layout]="model.layout">
|
||||
@for (section of model.sections; track section.id) {
|
||||
<section class="dynamic-section" [attr.data-section-type]="section.type">
|
||||
<section
|
||||
class="dynamic-section"
|
||||
[attr.data-section-type]="section.type"
|
||||
[attr.data-section-layout]="section.layout?.strategy ?? section.type"
|
||||
[style.--section-columns]="section.layout?.columns ?? null"
|
||||
[style.--section-gap]="section.layout?.gap ?? null"
|
||||
[style.--section-align]="section.layout?.align ?? null"
|
||||
[class.dynamic-section--desktop-hidden]="section.visibility?.desktop === false"
|
||||
[class.dynamic-section--tablet-hidden]="section.visibility?.tablet === false"
|
||||
[class.dynamic-section--mobile-hidden]="section.visibility?.mobile === false"
|
||||
>
|
||||
@for (widget of section.widgets; track widget.id) {
|
||||
<div class="dynamic-widget" [attr.data-widget-type]="widget.type">
|
||||
@if (resolveWidget(widget, section.id); as resolved) {
|
||||
@@ -31,8 +41,30 @@ import { ConfigService } from '../../core/config/config.service';
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dynamic-page-layout { display: grid; gap: 1rem; }
|
||||
.dynamic-section { display: grid; gap: 1rem; }
|
||||
.dynamic-page-layout { display: grid; gap: 1.5rem; }
|
||||
.dynamic-section {
|
||||
display: grid;
|
||||
gap: var(--section-gap, 1rem);
|
||||
align-items: var(--section-align, stretch);
|
||||
}
|
||||
|
||||
.dynamic-section[data-section-layout='grid'] {
|
||||
grid-template-columns: repeat(var(--section-columns, 1), minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dynamic-section[data-section-layout='hero'] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dynamic-section--desktop-hidden { display: none; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.dynamic-section--tablet-hidden { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.dynamic-section--mobile-hidden { display: none; }
|
||||
}
|
||||
`
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
<!-- novo VERSION - Modern Grid Layout -->
|
||||
@if (isMarketplaceNovo) {
|
||||
<div class="novo-home">
|
||||
<section class="novo-hero novo-hero-compact">
|
||||
<div class="novo-hero-content">
|
||||
<h1 class="novo-hero-title">{{ 'home.welcomeTo' | translate:{ brand: brandName } }}</h1>
|
||||
<p class="novo-hero-subtitle">{{ 'home.subtitle' | translate }}</p>
|
||||
<a [routerLink]="'/search' | langRoute" class="novo-hero-btn">
|
||||
{{ 'home.startSearch' | translate }}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Items Carousel -->
|
||||
<app-items-carousel />
|
||||
@if (pageModel()) {
|
||||
<app-dynamic-page-layout [model]="pageModel()" />
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<section class="novo-categories">
|
||||
@@ -89,31 +76,9 @@
|
||||
} @else {
|
||||
<!-- DEXAR VERSION - Redesigned 2026 -->
|
||||
<div class="dexar-home">
|
||||
<!-- Hero Section with Full Width Image -->
|
||||
<section class="dexar-hero">
|
||||
<div class="dexar-hero-overlay">
|
||||
<div class="dexar-hero-content">
|
||||
<h1 class="dexar-hero-title">{{ 'home.dexarHeroTitle' | translate }}</h1>
|
||||
<p class="dexar-hero-subtitle">{{ 'home.dexarHeroSubtitle' | translate }}</p>
|
||||
<p class="dexar-hero-tagline">{{ 'home.dexarHeroTagline' | translate }}</p>
|
||||
|
||||
<div class="dexar-hero-actions">
|
||||
<a (click)="scrollToCatalog()" class="dexar-btn-primary">
|
||||
{{ 'home.goToCatalog' | translate }}
|
||||
</a>
|
||||
<button (click)="navigateToSearch()" class="dexar-btn-secondary">
|
||||
{{ 'home.findProduct' | translate }}
|
||||
<svg width="11" height="16" viewBox="0 0 11 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1L9 8L1 15" stroke="#1E3C38" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Items Carousel -->
|
||||
<app-items-carousel />
|
||||
@if (pageModel()) {
|
||||
<app-dynamic-page-layout [model]="pageModel()" />
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<section class="dexar-categories">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Component, OnInit, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { LanguageService } from '../../services';
|
||||
import { ItemsCarouselComponent } from '../../components/items-carousel/items-carousel.component';
|
||||
import { DynamicPageLayoutComponent } from '../../layouts/containers/dynamic-page-layout.component';
|
||||
import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model';
|
||||
import { WebsiteRuntimeFacade } from '../../facades/website/website-runtime.facade';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
||||
@@ -10,7 +12,8 @@ import { Category } from '../../core/categories/models/category-domain.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
imports: [RouterLink, ItemsCarouselComponent, LangRoutePipe, TranslatePipe],
|
||||
standalone: true,
|
||||
imports: [RouterLink, DynamicPageLayoutComponent, LangRoutePipe, TranslatePipe],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -20,12 +23,14 @@ export class HomeComponent implements OnInit {
|
||||
private router: Router,
|
||||
private langService: LanguageService,
|
||||
private readonly uiRuntime: UiRuntimeFacade,
|
||||
private readonly categoryFacade: CategoryFacade
|
||||
private readonly categoryFacade: CategoryFacade,
|
||||
private readonly websiteRuntime: WebsiteRuntimeFacade
|
||||
) {}
|
||||
|
||||
categories = signal<Category[]>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
readonly pageModel = signal<PageRenderModel | null>(null);
|
||||
readonly skeletonSlots = Array.from({ length: 6 });
|
||||
|
||||
// Memoized computed values for performance
|
||||
@@ -63,9 +68,21 @@ export class HomeComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadHomepageSections();
|
||||
this.loadCategories();
|
||||
}
|
||||
|
||||
private loadHomepageSections(): void {
|
||||
this.websiteRuntime.getPageRenderModelForUrl(this.router.url).subscribe({
|
||||
next: (model) => {
|
||||
this.pageModel.set(model);
|
||||
},
|
||||
error: () => {
|
||||
this.pageModel.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadCategories(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { WidgetConfig } from './widget.model';
|
||||
|
||||
export type SectionViewport = 'desktop' | 'tablet' | 'mobile';
|
||||
|
||||
export type SectionLayoutStrategy = 'stack' | 'grid' | 'hero' | 'carousel' | 'split';
|
||||
|
||||
export interface SectionVisibilityConfig {
|
||||
desktop?: boolean;
|
||||
tablet?: boolean;
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
export interface SectionLayoutConfig {
|
||||
strategy?: SectionLayoutStrategy;
|
||||
columns?: number;
|
||||
gap?: string;
|
||||
align?: 'start' | 'center' | 'end' | 'stretch';
|
||||
@@ -11,6 +22,7 @@ export interface SectionConfig {
|
||||
type: string;
|
||||
order: number;
|
||||
layout?: SectionLayoutConfig;
|
||||
visibility?: SectionVisibilityConfig;
|
||||
widgets: WidgetConfig[];
|
||||
featureFlag?: string;
|
||||
visible?: boolean;
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
"id": "section-hero",
|
||||
"type": "hero",
|
||||
"order": 1,
|
||||
"layout": {
|
||||
"strategy": "hero",
|
||||
"columns": 1,
|
||||
"gap": "1.5rem",
|
||||
"align": "stretch"
|
||||
},
|
||||
"visibility": {
|
||||
"desktop": true,
|
||||
"tablet": true,
|
||||
"mobile": true
|
||||
},
|
||||
"visible": true,
|
||||
"widgets": [
|
||||
{
|
||||
@@ -33,6 +44,17 @@
|
||||
"id": "section-featured-products",
|
||||
"type": "content-grid",
|
||||
"order": 2,
|
||||
"layout": {
|
||||
"strategy": "carousel",
|
||||
"columns": 1,
|
||||
"gap": "1rem",
|
||||
"align": "stretch"
|
||||
},
|
||||
"visibility": {
|
||||
"desktop": true,
|
||||
"tablet": true,
|
||||
"mobile": true
|
||||
},
|
||||
"visible": true,
|
||||
"widgets": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user