Sprint 8: add widget manifest and data source engine
This commit is contained in:
@@ -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()) {
|
||||
<section class="novo-categories">
|
||||
<div class="novo-section-header">
|
||||
<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>
|
||||
@if (loading()) {
|
||||
<section class="home-loading">Loading homepage...</section>
|
||||
} @else if (pageModel()) {
|
||||
<app-dynamic-page-layout [model]="pageModel()" />
|
||||
} @else {
|
||||
<!-- DEXAR VERSION - Redesigned 2026 -->
|
||||
<div class="dexar-home">
|
||||
@if (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 {
|
||||
<div class="dexar-categories-grid">
|
||||
@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>
|
||||
<section class="home-empty">No page configuration found.</section>
|
||||
}
|
||||
|
||||
@@ -1,156 +1,41 @@
|
||||
import { Component, OnInit, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { LanguageService } from '../../services';
|
||||
import { Component, OnInit, signal, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
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';
|
||||
import { CategoryFacade } from '../../facades/platform/category.facade';
|
||||
import { Category } from '../../core/categories/models/category-domain.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [RouterLink, DynamicPageLayoutComponent, LangRoutePipe, TranslatePipe],
|
||||
imports: [DynamicPageLayoutComponent],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class HomeComponent implements OnInit {
|
||||
constructor(
|
||||
private router: Router,
|
||||
private langService: LanguageService,
|
||||
private readonly uiRuntime: UiRuntimeFacade,
|
||||
private readonly categoryFacade: CategoryFacade,
|
||||
private readonly router: Router,
|
||||
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
|
||||
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 {
|
||||
this.loadHomepageSections();
|
||||
this.loadCategories();
|
||||
}
|
||||
|
||||
private loadHomepageSections(): void {
|
||||
this.websiteRuntime.getPageRenderModelForUrl(this.router.url).subscribe({
|
||||
next: (model) => {
|
||||
this.pageModel.set(model);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user