diff --git a/docs/Section-Engine-Report.md b/docs/Section-Engine-Report.md new file mode 100644 index 0000000..37e731c --- /dev/null +++ b/docs/Section-Engine-Report.md @@ -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. \ No newline at end of file diff --git a/src/app/dynamic-renderer/page-renderer/page-renderer.service.ts b/src/app/dynamic-renderer/page-renderer/page-renderer.service.ts index 90bf7b2..3b9a6d2 100644 --- a/src/app/dynamic-renderer/page-renderer/page-renderer.service.ts +++ b/src/app/dynamic-renderer/page-renderer/page-renderer.service.ts @@ -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); } } diff --git a/src/app/dynamic-renderer/section-engine/section-engine.service.ts b/src/app/dynamic-renderer/section-engine/section-engine.service.ts new file mode 100644 index 0000000..5a7f5e7 --- /dev/null +++ b/src/app/dynamic-renderer/section-engine/section-engine.service.ts @@ -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 + }; + } +} \ No newline at end of file diff --git a/src/app/dynamic-renderer/section-renderer/section-renderer.model.ts b/src/app/dynamic-renderer/section-renderer/section-renderer.model.ts index 3cad101..c62341e 100644 --- a/src/app/dynamic-renderer/section-renderer/section-renderer.model.ts +++ b/src/app/dynamic-renderer/section-renderer/section-renderer.model.ts @@ -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[]; diff --git a/src/app/dynamic-renderer/section-renderer/section-renderer.service.ts b/src/app/dynamic-renderer/section-renderer/section-renderer.service.ts index 5dcd2e6..7f82186 100644 --- a/src/app/dynamic-renderer/section-renderer/section-renderer.service.ts +++ b/src/app/dynamic-renderer/section-renderer/section-renderer.service.ts @@ -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, diff --git a/src/app/layouts/containers/dynamic-page-layout.component.ts b/src/app/layouts/containers/dynamic-page-layout.component.ts index 2ee227f..77ce61b 100644 --- a/src/app/layouts/containers/dynamic-page-layout.component.ts +++ b/src/app/layouts/containers/dynamic-page-layout.component.ts @@ -16,7 +16,17 @@ import { ConfigService } from '../../core/config/config.service'; @if (model) {
@for (section of model.sections; track section.id) { -
+
@for (widget of section.widgets; track widget.id) {
@if (resolveWidget(widget, section.id); as resolved) { @@ -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 diff --git a/src/app/pages/home/home.component.html b/src/app/pages/home/home.component.html index 4dd1288..f1b0a60 100644 --- a/src/app/pages/home/home.component.html +++ b/src/app/pages/home/home.component.html @@ -1,22 +1,9 @@  @if (isMarketplaceNovo) {
-
-
-

{{ 'home.welcomeTo' | translate:{ brand: brandName } }}

-

{{ 'home.subtitle' | translate }}

- - {{ 'home.startSearch' | translate }} - - - - - -
-
- - - + @if (pageModel()) { + + } @if (loading()) {
@@ -89,31 +76,9 @@ } @else {
- -
-
-
-

{{ 'home.dexarHeroTitle' | translate }}

-

{{ 'home.dexarHeroSubtitle' | translate }}

-

{{ 'home.dexarHeroTagline' | translate }}

- -
- - {{ 'home.goToCatalog' | translate }} - - -
-
-
-
- - - + @if (pageModel()) { + + } @if (loading()) {
diff --git a/src/app/pages/home/home.component.ts b/src/app/pages/home/home.component.ts index 6f99d7d..7f0c756 100644 --- a/src/app/pages/home/home.component.ts +++ b/src/app/pages/home/home.component.ts @@ -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([]); loading = signal(true); error = signal(null); + readonly pageModel = signal(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); diff --git a/src/app/shared/models/config/section.model.ts b/src/app/shared/models/config/section.model.ts index 33a5f6b..2757892 100644 --- a/src/app/shared/models/config/section.model.ts +++ b/src/app/shared/models/config/section.model.ts @@ -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; diff --git a/src/assets/mock/bootstrap/homepage.json b/src/assets/mock/bootstrap/homepage.json index d1224a9..d3b9ef8 100644 --- a/src/assets/mock/bootstrap/homepage.json +++ b/src/assets/mock/bootstrap/homepage.json @@ -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": [ {