Sprint 7: add section engine

This commit is contained in:
sdarbinyan
2026-07-05 01:55:41 +04:00
parent d4a5daeb4c
commit 91d9444875
10 changed files with 203 additions and 62 deletions

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

View File

@@ -1,25 +1,13 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { PageConfig } from '../../shared/models/config'; import { PageConfig } from '../../shared/models/config';
import { PageRenderModel } from './page-renderer.model'; 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' }) @Injectable({ providedIn: 'root' })
export class PageRendererService { export class PageRendererService {
constructor(private readonly sectionRenderer: SectionRendererService) {} constructor(private readonly sectionEngine: SectionEngineService) {}
toRenderModel(page: PageConfig): PageRenderModel { toRenderModel(page: PageConfig): PageRenderModel {
const sections = (page.sections ?? []) return this.sectionEngine.toPageRenderModel(page);
.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
};
} }
} }

View File

@@ -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
};
}
}

View File

@@ -1,10 +1,13 @@
import { SectionConfig } from '../../shared/models/config'; import { SectionConfig } from '../../shared/models/config';
import { SectionLayoutConfig, SectionVisibilityConfig } from '../../shared/models/config';
import { WidgetRenderNode } from '../widget-host/widget-host.model'; import { WidgetRenderNode } from '../widget-host/widget-host.model';
export interface SectionRenderNode { export interface SectionRenderNode {
id: string; id: string;
type: string; type: string;
order: number; order: number;
layout?: SectionLayoutConfig;
visibility?: SectionVisibilityConfig;
featureFlag?: string; featureFlag?: string;
visible?: boolean; visible?: boolean;
widgets: WidgetRenderNode[]; widgets: WidgetRenderNode[];

View File

@@ -22,6 +22,8 @@ export class SectionRendererService {
id: section.id, id: section.id,
type: section.type, type: section.type,
order: section.order, order: section.order,
layout: section.layout,
visibility: section.visibility,
featureFlag: section.featureFlag, featureFlag: section.featureFlag,
visible: section.visible, visible: section.visible,
widgets, widgets,

View File

@@ -16,7 +16,17 @@ import { ConfigService } from '../../core/config/config.service';
@if (model) { @if (model) {
<main class="dynamic-page-layout" [attr.data-layout]="model.layout"> <main class="dynamic-page-layout" [attr.data-layout]="model.layout">
@for (section of model.sections; track section.id) { @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) { @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.id); as resolved) {
@@ -31,8 +41,30 @@ import { ConfigService } from '../../core/config/config.service';
`, `,
styles: [ styles: [
` `
.dynamic-page-layout { display: grid; gap: 1rem; } .dynamic-page-layout { display: grid; gap: 1.5rem; }
.dynamic-section { display: grid; gap: 1rem; } .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 changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,22 +1,9 @@
<!-- novo VERSION - Modern Grid Layout --> <!-- novo VERSION - Modern Grid Layout -->
@if (isMarketplaceNovo) { @if (isMarketplaceNovo) {
<div class="novo-home"> <div class="novo-home">
<section class="novo-hero novo-hero-compact"> @if (pageModel()) {
<div class="novo-hero-content"> <app-dynamic-page-layout [model]="pageModel()" />
<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 (loading()) { @if (loading()) {
<section class="novo-categories"> <section class="novo-categories">
@@ -89,31 +76,9 @@
} @else { } @else {
<!-- DEXAR VERSION - Redesigned 2026 --> <!-- DEXAR VERSION - Redesigned 2026 -->
<div class="dexar-home"> <div class="dexar-home">
<!-- Hero Section with Full Width Image --> @if (pageModel()) {
<section class="dexar-hero"> <app-dynamic-page-layout [model]="pageModel()" />
<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 (loading()) { @if (loading()) {
<section class="dexar-categories"> <section class="dexar-categories">

View File

@@ -1,7 +1,9 @@
import { Component, OnInit, signal, computed, ChangeDetectionStrategy } from '@angular/core'; import { Component, OnInit, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { LanguageService } from '../../services'; 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 { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade'; import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
@@ -10,7 +12,8 @@ import { Category } from '../../core/categories/models/category-domain.model';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
imports: [RouterLink, ItemsCarouselComponent, LangRoutePipe, TranslatePipe], standalone: true,
imports: [RouterLink, DynamicPageLayoutComponent, LangRoutePipe, TranslatePipe],
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'], styleUrls: ['./home.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -20,12 +23,14 @@ export class HomeComponent implements OnInit {
private router: Router, private router: Router,
private langService: LanguageService, private langService: LanguageService,
private readonly uiRuntime: UiRuntimeFacade, private readonly uiRuntime: UiRuntimeFacade,
private readonly categoryFacade: CategoryFacade private readonly categoryFacade: CategoryFacade,
private readonly websiteRuntime: WebsiteRuntimeFacade
) {} ) {}
categories = signal<Category[]>([]); 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 skeletonSlots = Array.from({ length: 6 }); readonly skeletonSlots = Array.from({ length: 6 });
// Memoized computed values for performance // Memoized computed values for performance
@@ -63,9 +68,21 @@ export class HomeComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.loadHomepageSections();
this.loadCategories(); 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 { loadCategories(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null); this.error.set(null);

View File

@@ -1,6 +1,17 @@
import { WidgetConfig } from './widget.model'; 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 { export interface SectionLayoutConfig {
strategy?: SectionLayoutStrategy;
columns?: number; columns?: number;
gap?: string; gap?: string;
align?: 'start' | 'center' | 'end' | 'stretch'; align?: 'start' | 'center' | 'end' | 'stretch';
@@ -11,6 +22,7 @@ export interface SectionConfig {
type: string; type: string;
order: number; order: number;
layout?: SectionLayoutConfig; layout?: SectionLayoutConfig;
visibility?: SectionVisibilityConfig;
widgets: WidgetConfig[]; widgets: WidgetConfig[];
featureFlag?: string; featureFlag?: string;
visible?: boolean; visible?: boolean;

View File

@@ -14,6 +14,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": [
{ {
@@ -33,6 +44,17 @@
"id": "section-featured-products", "id": "section-featured-products",
"type": "content-grid", "type": "content-grid",
"order": 2, "order": 2,
"layout": {
"strategy": "carousel",
"columns": 1,
"gap": "1rem",
"align": "stretch"
},
"visibility": {
"desktop": true,
"tablet": true,
"mobile": true
},
"visible": true, "visible": true,
"widgets": [ "widgets": [
{ {