refactor: finalize bootstrap-driven layout and widget runtime
This commit is contained in:
@@ -24,15 +24,21 @@
|
|||||||
<div class="app-footer__group">
|
<div class="app-footer__group">
|
||||||
<h4>{{ 'footer.payment' | translate }}</h4>
|
<h4>{{ 'footer.payment' | translate }}</h4>
|
||||||
<div class="app-footer__payments">
|
<div class="app-footer__payments">
|
||||||
<img src="/assets/images/mir-logo.svg" alt="MIR" loading="lazy" width="40" height="28" />
|
@for (icon of paymentIcons(); track icon.src) {
|
||||||
<img src="/assets/images/visa-logo.svg" alt="Visa" loading="lazy" width="40" height="28" />
|
<img [src]="icon.src" [alt]="icon.alt" loading="lazy" [width]="icon.width" [height]="icon.height" />
|
||||||
<img src="/assets/images/mastercard-logo.svg" alt="Mastercard" loading="lazy" width="40" height="28" />
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="app-footer__bottom">
|
<div class="app-footer__bottom">
|
||||||
<p>© {{ currentYear }} {{ brandName }}. {{ 'footer.allRightsReserved' | translate }}</p>
|
<p>
|
||||||
|
@if (copyrightText()) {
|
||||||
|
{{ copyrightText() }}
|
||||||
|
} @else {
|
||||||
|
© {{ currentYear }} {{ brandName }}. {{ 'footer.allRightsReserved' | translate }}
|
||||||
|
}
|
||||||
|
</p>
|
||||||
@if (contactEmail) {
|
@if (contactEmail) {
|
||||||
<a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>
|
<a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
|||||||
import { LogoComponent } from '../logo/logo.component';
|
import { LogoComponent } from '../logo/logo.component';
|
||||||
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { ConfigService } from '../../core/config/config.service';
|
import { FooterPaymentIcon, FooterResolvedGroup, FooterResolverService } from '../../core/config/footer-resolver.service';
|
||||||
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
|
|
||||||
import { BootstrapConfig, FooterNavigationGroupConfig, NavigationConfig, NavigationItemConfig, FooterNavigationItemConfig } from '../../shared/models/config';
|
|
||||||
import { LanguageService } from '../../services/language.service';
|
|
||||||
|
|
||||||
interface FooterResolvedItem {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
route: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FooterResolvedGroup {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
items: FooterResolvedItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-footer',
|
selector: 'app-footer',
|
||||||
@@ -31,19 +16,23 @@ interface FooterResolvedGroup {
|
|||||||
})
|
})
|
||||||
export class FooterComponent {
|
export class FooterComponent {
|
||||||
readonly footerGroups = signal<FooterResolvedGroup[]>([]);
|
readonly footerGroups = signal<FooterResolvedGroup[]>([]);
|
||||||
|
readonly paymentIcons = signal<FooterPaymentIcon[]>([]);
|
||||||
|
readonly copyrightText = signal('');
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly uiRuntime: UiRuntimeFacade,
|
private readonly uiRuntime: UiRuntimeFacade,
|
||||||
private readonly configService: ConfigService,
|
private readonly footerResolver: FooterResolverService
|
||||||
private readonly staticPageResolver: StaticPageResolverService,
|
|
||||||
private readonly languageService: LanguageService
|
|
||||||
) {
|
) {
|
||||||
this.configService.loadBootstrap().pipe(take(1)).subscribe({
|
this.footerResolver.resolveFooterModel().pipe(take(1)).subscribe({
|
||||||
next: bootstrap => {
|
next: model => {
|
||||||
this.footerGroups.set(this.resolveFooterGroups(bootstrap));
|
this.footerGroups.set(model.groups);
|
||||||
|
this.paymentIcons.set(model.paymentIcons);
|
||||||
|
this.copyrightText.set(model.copyrightText);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.footerGroups.set([]);
|
this.footerGroups.set([]);
|
||||||
|
this.paymentIcons.set([]);
|
||||||
|
this.copyrightText.set('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -57,120 +46,4 @@ export class FooterComponent {
|
|||||||
get contactEmail(): string {
|
get contactEmail(): string {
|
||||||
return this.uiRuntime.contactEmail();
|
return this.uiRuntime.contactEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveFooterGroups(bootstrap: BootstrapConfig): FooterResolvedGroup[] {
|
|
||||||
const footer = bootstrap.navigation?.footer ?? [];
|
|
||||||
const lang = this.languageService.currentLanguage();
|
|
||||||
|
|
||||||
if (this.isGroupedFooter(footer)) {
|
|
||||||
return footer
|
|
||||||
.map((group, index) => this.resolveGroupFromConfig(group, bootstrap, lang, index))
|
|
||||||
.filter((group): group is FooterResolvedGroup => group != null && group.items.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = (footer as NavigationItemConfig[])
|
|
||||||
.map(item => this.resolveLegacyItem(item, bootstrap, lang))
|
|
||||||
.filter((item): item is FooterResolvedItem => item != null);
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{
|
|
||||||
id: 'footer-group-default',
|
|
||||||
title: '',
|
|
||||||
items
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveGroupFromConfig(
|
|
||||||
group: FooterNavigationGroupConfig,
|
|
||||||
bootstrap: BootstrapConfig,
|
|
||||||
lang: string,
|
|
||||||
index: number
|
|
||||||
): FooterResolvedGroup | null {
|
|
||||||
const items = (group.items ?? [])
|
|
||||||
.map(item => this.resolveGroupItem(item, bootstrap, lang))
|
|
||||||
.filter((item): item is FooterResolvedItem => item != null);
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `footer-group-${index}`,
|
|
||||||
title: this.staticPageResolver.resolveFooterTitle(group.groupTitle, lang),
|
|
||||||
items
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveGroupItem(
|
|
||||||
item: FooterNavigationItemConfig,
|
|
||||||
bootstrap: BootstrapConfig,
|
|
||||||
lang: string
|
|
||||||
): FooterResolvedItem | null {
|
|
||||||
if (item.visible === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === 'staticPage' && item.key) {
|
|
||||||
const staticPage = this.staticPageResolver.resolveByKeyFromBootstrap(bootstrap, item.key, lang);
|
|
||||||
if (!staticPage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `static-${item.key}`,
|
|
||||||
label: staticPage.title,
|
|
||||||
route: staticPage.route
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = typeof item.label === 'string'
|
|
||||||
? item.label
|
|
||||||
: this.staticPageResolver.resolveFooterTitle(item.label, lang);
|
|
||||||
|
|
||||||
if (!item.route || !label) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `${item.type}-${label}`,
|
|
||||||
label,
|
|
||||||
route: item.route
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveLegacyItem(item: NavigationItemConfig, bootstrap: BootstrapConfig, lang: string): FooterResolvedItem | null {
|
|
||||||
if (item.visible === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === 'staticPage' && item.key) {
|
|
||||||
const staticPage = this.staticPageResolver.resolveByKeyFromBootstrap(bootstrap, item.key, lang);
|
|
||||||
if (!staticPage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
label: staticPage.title,
|
|
||||||
route: staticPage.route
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.route) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
label: item.labelKey ?? item.id,
|
|
||||||
route: item.route
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private isGroupedFooter(footer: NavigationConfig['footer']): footer is FooterNavigationGroupConfig[] {
|
|
||||||
return Array.isArray(footer) && footer.length > 0 && 'items' in footer[0];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
246
src/app/core/config/footer-resolver.service.ts
Normal file
246
src/app/core/config/footer-resolver.service.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import { ConfigService } from './config.service';
|
||||||
|
import {
|
||||||
|
BootstrapConfig,
|
||||||
|
FooterConfig,
|
||||||
|
FooterNavigationGroupConfig,
|
||||||
|
FooterNavigationItemConfig,
|
||||||
|
NavigationConfig,
|
||||||
|
NavigationItemConfig
|
||||||
|
} from '../../shared/models/config';
|
||||||
|
import { StaticPageResolverService } from './static-page-resolver.service';
|
||||||
|
import { LanguageService } from '../../services/language.service';
|
||||||
|
|
||||||
|
export interface FooterResolvedItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
route: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterResolvedGroup {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
items: FooterResolvedItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterPaymentIcon {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterResolvedModel {
|
||||||
|
groups: FooterResolvedGroup[];
|
||||||
|
paymentIcons: FooterPaymentIcon[];
|
||||||
|
copyrightText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class FooterResolverService {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly staticPageResolver: StaticPageResolverService,
|
||||||
|
private readonly languageService: LanguageService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
resolveFooterModel(): Observable<FooterResolvedModel> {
|
||||||
|
return this.configService.loadBootstrap().pipe(
|
||||||
|
map((bootstrap) => ({
|
||||||
|
groups: this.resolveFooterGroups(bootstrap),
|
||||||
|
paymentIcons: this.resolvePaymentIcons(bootstrap.footer),
|
||||||
|
copyrightText: this.resolveCopyrightText(bootstrap)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveFooterGroups(bootstrap: BootstrapConfig): FooterResolvedGroup[] {
|
||||||
|
const footer = bootstrap.navigation?.footer ?? [];
|
||||||
|
const lang = this.languageService.currentLanguage();
|
||||||
|
const legalKeys = bootstrap.footer?.legalPageKeys ?? [];
|
||||||
|
const legalItems = legalKeys
|
||||||
|
.map(key => {
|
||||||
|
const page = this.staticPageResolver.resolveByKeyFromBootstrap(bootstrap, key, lang);
|
||||||
|
if (!page) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `legal-${key}`,
|
||||||
|
label: page.title,
|
||||||
|
route: page.route
|
||||||
|
} as FooterResolvedItem;
|
||||||
|
})
|
||||||
|
.filter((item): item is FooterResolvedItem => item != null);
|
||||||
|
|
||||||
|
if (this.isGroupedFooter(footer)) {
|
||||||
|
const groups = footer
|
||||||
|
.map((group, index) => this.resolveGroupFromConfig(group, bootstrap, lang, index))
|
||||||
|
.filter((group): group is FooterResolvedGroup => group != null && group.items.length > 0);
|
||||||
|
|
||||||
|
if (legalItems.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
id: 'footer-group-legal',
|
||||||
|
title: 'Legal',
|
||||||
|
items: legalItems
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = (footer as NavigationItemConfig[])
|
||||||
|
.map(item => this.resolveLegacyItem(item, bootstrap, lang))
|
||||||
|
.filter((item): item is FooterResolvedItem => item != null);
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return legalItems.length
|
||||||
|
? [{ id: 'footer-group-legal', title: 'Legal', items: legalItems }]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: FooterResolvedGroup[] = [{
|
||||||
|
id: 'footer-group-default',
|
||||||
|
title: '',
|
||||||
|
items
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (legalItems.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
id: 'footer-group-legal',
|
||||||
|
title: 'Legal',
|
||||||
|
items: legalItems
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveGroupFromConfig(
|
||||||
|
group: FooterNavigationGroupConfig,
|
||||||
|
bootstrap: BootstrapConfig,
|
||||||
|
lang: string,
|
||||||
|
index: number
|
||||||
|
): FooterResolvedGroup | null {
|
||||||
|
const items = (group.items ?? [])
|
||||||
|
.map(item => this.resolveGroupItem(item, bootstrap, lang))
|
||||||
|
.filter((item): item is FooterResolvedItem => item != null);
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `footer-group-${index}`,
|
||||||
|
title: this.staticPageResolver.resolveFooterTitle(group.groupTitle, lang),
|
||||||
|
items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveGroupItem(
|
||||||
|
item: FooterNavigationItemConfig,
|
||||||
|
bootstrap: BootstrapConfig,
|
||||||
|
lang: string
|
||||||
|
): FooterResolvedItem | null {
|
||||||
|
if (item.visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'staticPage' && item.key) {
|
||||||
|
const staticPage = this.staticPageResolver.resolveByKeyFromBootstrap(bootstrap, item.key, lang);
|
||||||
|
if (!staticPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `static-${item.key}`,
|
||||||
|
label: staticPage.title,
|
||||||
|
route: staticPage.route
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = typeof item.label === 'string'
|
||||||
|
? item.label
|
||||||
|
: this.staticPageResolver.resolveFooterTitle(item.label, lang);
|
||||||
|
|
||||||
|
if (!item.route || !label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${item.type}-${label}`,
|
||||||
|
label,
|
||||||
|
route: item.route
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveLegacyItem(item: NavigationItemConfig, bootstrap: BootstrapConfig, lang: string): FooterResolvedItem | null {
|
||||||
|
if (item.visible === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'staticPage' && item.key) {
|
||||||
|
const staticPage = this.staticPageResolver.resolveByKeyFromBootstrap(bootstrap, item.key, lang);
|
||||||
|
if (!staticPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
label: staticPage.title,
|
||||||
|
route: staticPage.route
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.route) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
label: item.labelKey ?? item.id,
|
||||||
|
route: item.route
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isGroupedFooter(footer: NavigationConfig['footer']): footer is FooterNavigationGroupConfig[] {
|
||||||
|
return Array.isArray(footer) && footer.length > 0 && 'items' in footer[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePaymentIcons(footerConfig: FooterConfig | undefined): FooterPaymentIcon[] {
|
||||||
|
const fallback: FooterPaymentIcon[] = [
|
||||||
|
{ src: '/assets/images/mir-logo.svg', alt: 'MIR', width: 40, height: 28 },
|
||||||
|
{ src: '/assets/images/visa-logo.svg', alt: 'Visa', width: 40, height: 28 },
|
||||||
|
{ src: '/assets/images/mastercard-logo.svg', alt: 'Mastercard', width: 40, height: 28 }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!footerConfig?.paymentIcons?.length) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return footerConfig.paymentIcons
|
||||||
|
.filter(icon => !!icon?.src)
|
||||||
|
.map(icon => ({
|
||||||
|
src: icon.src,
|
||||||
|
alt: icon.alt || 'Payment icon',
|
||||||
|
width: icon.width ?? 40,
|
||||||
|
height: icon.height ?? 28
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCopyrightText(bootstrap: BootstrapConfig): string {
|
||||||
|
const configured = bootstrap.footer?.copyrightText;
|
||||||
|
const lang = this.languageService.currentLanguage();
|
||||||
|
|
||||||
|
if (typeof configured === 'string' && configured.trim()) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configured && typeof configured === 'object') {
|
||||||
|
return this.staticPageResolver.resolveFooterTitle(configured, lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,17 @@ import { Injectable } from '@angular/core';
|
|||||||
import { PageConfig } from '../../shared/models/config';
|
import { PageConfig } from '../../shared/models/config';
|
||||||
import { PageRenderModel } from '../page-renderer/page-renderer.model';
|
import { PageRenderModel } from '../page-renderer/page-renderer.model';
|
||||||
import { SectionRendererService } from '../section-renderer/section-renderer.service';
|
import { SectionRendererService } from '../section-renderer/section-renderer.service';
|
||||||
|
import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config';
|
||||||
|
import { SectionConfig } from '../../shared/models/config';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SectionEngineService {
|
export class SectionEngineService {
|
||||||
constructor(private readonly sectionRenderer: SectionRendererService) {}
|
constructor(private readonly sectionRenderer: SectionRendererService) {}
|
||||||
|
|
||||||
toPageRenderModel(page: PageConfig): PageRenderModel {
|
toPageRenderModel(page: PageConfig): PageRenderModel {
|
||||||
const sections = (page.sections ?? [])
|
const layoutType = this.resolveLayoutType(page.layout);
|
||||||
|
|
||||||
|
const sections = this.normalizeSectionsByLayout(page.sections ?? [], layoutType)
|
||||||
.filter((section) => section.visible !== false)
|
.filter((section) => section.visible !== false)
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map((section) => this.sectionRenderer.toRenderNode(section));
|
.map((section) => this.sectionRenderer.toRenderNode(section));
|
||||||
@@ -17,9 +21,111 @@ export class SectionEngineService {
|
|||||||
id: page.id,
|
id: page.id,
|
||||||
key: page.key,
|
key: page.key,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
layout: page.layout,
|
layout: layoutType,
|
||||||
sections,
|
sections,
|
||||||
source: page
|
source: page
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveLayoutType(layout: PageConfig['layout']): string {
|
||||||
|
if (typeof layout === 'string') {
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (layout as PlatformLayoutConfig)?.type ?? 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSectionsByLayout(sections: PageConfig['sections'], layoutType: string): PageConfig['sections'] {
|
||||||
|
const normalized = [...sections];
|
||||||
|
const lowerLayout = (layoutType as PlatformLayoutType).toLowerCase();
|
||||||
|
|
||||||
|
if (lowerLayout === 'minimal') {
|
||||||
|
return normalized.filter(section => {
|
||||||
|
const type = section.type.toLowerCase();
|
||||||
|
return type === 'product-collection' || type === 'products' || type === 'catalog';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerLayout === 'sidebar-left') {
|
||||||
|
return this.composeSidebarLayout(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerLayout === 'carousel-home') {
|
||||||
|
return normalized.sort((left, right) => this.getCarouselPriority(left.type) - this.getCarouselPriority(right.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerLayout === 'default') {
|
||||||
|
return normalized.sort((left, right) => this.getDefaultPriority(left.type) - this.getDefaultPriority(right.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultPriority(type: string): number {
|
||||||
|
const normalizedType = type.toLowerCase();
|
||||||
|
if (normalizedType === 'categories') return 10;
|
||||||
|
if (normalizedType === 'product-collection' || normalizedType === 'products') return 20;
|
||||||
|
if (normalizedType === 'hero') return 30;
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSidebarPriority(type: string): number {
|
||||||
|
const normalizedType = type.toLowerCase();
|
||||||
|
if (normalizedType === 'categories') return 10;
|
||||||
|
if (normalizedType === 'product-collection' || normalizedType === 'products') return 20;
|
||||||
|
if (normalizedType === 'hero') return 30;
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCarouselPriority(type: string): number {
|
||||||
|
const normalizedType = type.toLowerCase();
|
||||||
|
if (normalizedType === 'hero' || normalizedType === 'banner') return 10;
|
||||||
|
if (normalizedType === 'product-collection' || normalizedType === 'product-carousel') return 20;
|
||||||
|
if (normalizedType === 'categories') return 30;
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private composeSidebarLayout(sections: PageConfig['sections']): PageConfig['sections'] {
|
||||||
|
const categoriesSection = sections.find(section => section.type.toLowerCase() === 'categories');
|
||||||
|
const productsSection = sections.find(section => {
|
||||||
|
const type = section.type.toLowerCase();
|
||||||
|
return type === 'product-collection' || type === 'products' || type === 'catalog';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!categoriesSection || !productsSection) {
|
||||||
|
return [...sections].sort((left, right) => this.getSidebarPriority(left.type) - this.getSidebarPriority(right.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined: SectionConfig = {
|
||||||
|
...categoriesSection,
|
||||||
|
id: `${categoriesSection.id}-sidebar-layout`,
|
||||||
|
type: 'sidebar-left',
|
||||||
|
order: Math.min(categoriesSection.order, productsSection.order),
|
||||||
|
layout: {
|
||||||
|
strategy: 'grid',
|
||||||
|
columns: 2,
|
||||||
|
gap: '1.5rem',
|
||||||
|
align: 'start'
|
||||||
|
},
|
||||||
|
widgets: [
|
||||||
|
...categoriesSection.widgets.map((widget, index) => ({
|
||||||
|
...widget,
|
||||||
|
order: index,
|
||||||
|
padding: widget.padding ?? '0',
|
||||||
|
visibility: widget.visibility ?? { desktop: true, tablet: true, mobile: true }
|
||||||
|
})),
|
||||||
|
...productsSection.widgets.map((widget, index) => ({
|
||||||
|
...widget,
|
||||||
|
order: 100 + index,
|
||||||
|
padding: widget.padding ?? '0',
|
||||||
|
visibility: widget.visibility ?? { desktop: true, tablet: true, mobile: true }
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
combined,
|
||||||
|
...sections.filter(section => section.id !== categoriesSection.id && section.id !== productsSection.id)
|
||||||
|
].sort((left, right) => left.order - right.order);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,10 +8,14 @@ export class SectionRendererService {
|
|||||||
toRenderNode(section: SectionConfig): SectionRenderNode {
|
toRenderNode(section: SectionConfig): SectionRenderNode {
|
||||||
const widgets = (section.widgets ?? [])
|
const widgets = (section.widgets ?? [])
|
||||||
.filter(widget => widget.visible !== false)
|
.filter(widget => widget.visible !== false)
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
.map<WidgetRenderNode>(widget => ({
|
.map<WidgetRenderNode>(widget => ({
|
||||||
id: widget.id,
|
id: widget.id,
|
||||||
type: widget.type,
|
type: widget.type,
|
||||||
version: widget.version,
|
version: widget.version,
|
||||||
|
order: widget.order,
|
||||||
|
padding: widget.padding,
|
||||||
|
visibility: widget.visibility,
|
||||||
props: widget.props ?? {},
|
props: widget.props ?? {},
|
||||||
featureFlag: widget.featureFlag,
|
featureFlag: widget.featureFlag,
|
||||||
visible: widget.visible,
|
visible: widget.visible,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Type } from '@angular/core';
|
import { Type } from '@angular/core';
|
||||||
import { WidgetConfig } from '../../shared/models/config';
|
import { WidgetConfig } from '../../shared/models/config';
|
||||||
import { SectionConfig } from '../../shared/models/config';
|
import { SectionConfig } from '../../shared/models/config';
|
||||||
|
import { WidgetVisibilityConfig } from '../../shared/models/config';
|
||||||
|
|
||||||
export interface WidgetRenderNode {
|
export interface WidgetRenderNode {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
order?: number;
|
||||||
|
padding?: string;
|
||||||
|
visibility?: WidgetVisibilityConfig;
|
||||||
props: Record<string, unknown>;
|
props: Record<string, unknown>;
|
||||||
featureFlag?: string;
|
featureFlag?: string;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
|
|||||||
@@ -34,7 +34,14 @@ import { Category } from '../../core/categories/models/category-domain.model';
|
|||||||
[style.--section-align]="section.layout?.align ?? null"
|
[style.--section-align]="section.layout?.align ?? null"
|
||||||
>
|
>
|
||||||
@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"
|
||||||
|
[style.--widget-padding]="widget.padding ?? null"
|
||||||
|
[class.dynamic-widget--desktop-hidden]="widget.visibility?.desktop === false"
|
||||||
|
[class.dynamic-widget--tablet-hidden]="widget.visibility?.tablet === false"
|
||||||
|
[class.dynamic-widget--mobile-hidden]="widget.visibility?.mobile === false"
|
||||||
|
>
|
||||||
@if (resolveWidget(widget, section, model.id) | async; as resolved) {
|
@if (resolveWidget(widget, section, model.id) | async; as resolved) {
|
||||||
<ng-container *ngComponentOutlet="$any(resolved).component; inputs: { section: $any(resolved).section, data: $any(resolved).data }; outputs: { categorySelected: onCategorySelected.bind(this) }"></ng-container>
|
<ng-container *ngComponentOutlet="$any(resolved).component; inputs: { section: $any(resolved).section, data: $any(resolved).data }; outputs: { categorySelected: onCategorySelected.bind(this) }"></ng-container>
|
||||||
}
|
}
|
||||||
@@ -51,12 +58,28 @@ import { Category } from '../../core/categories/models/category-domain.model';
|
|||||||
`
|
`
|
||||||
.dynamic-page-layout { display: block; }
|
.dynamic-page-layout { display: block; }
|
||||||
|
|
||||||
|
.dynamic-page-layout[data-layout='sidebar-left'] .dynamic-section[data-section-type='sidebar-left'] .dynamic-section__content {
|
||||||
|
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-page-layout[data-layout='minimal'] .dynamic-section[data-section-type='hero'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-page-layout[data-layout='carousel-home'] .dynamic-section[data-section-type='hero'] {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
.dynamic-section__content {
|
.dynamic-section__content {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--section-gap, 1rem);
|
gap: var(--section-gap, 1rem);
|
||||||
align-items: var(--section-align, stretch);
|
align-items: var(--section-align, stretch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dynamic-widget {
|
||||||
|
padding: var(--widget-padding, 0);
|
||||||
|
}
|
||||||
|
|
||||||
.dynamic-section[data-section-layout='grid'] .dynamic-section__content {
|
.dynamic-section[data-section-layout='grid'] .dynamic-section__content {
|
||||||
grid-template-columns: repeat(var(--section-columns, 1), minmax(0, 1fr));
|
grid-template-columns: repeat(var(--section-columns, 1), minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -69,11 +92,19 @@ import { Category } from '../../core/categories/models/category-domain.model';
|
|||||||
|
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.dynamic-section--tablet-hidden { display: none; }
|
.dynamic-section--tablet-hidden { display: none; }
|
||||||
|
.dynamic-widget--tablet-hidden { display: none; }
|
||||||
|
|
||||||
|
.dynamic-page-layout[data-layout='sidebar-left'] .dynamic-section[data-section-type='sidebar-left'] .dynamic-section__content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.dynamic-section--mobile-hidden { display: none; }
|
.dynamic-section--mobile-hidden { display: none; }
|
||||||
|
.dynamic-widget--mobile-hidden { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dynamic-widget--desktop-hidden { display: none; }
|
||||||
`
|
`
|
||||||
],
|
],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { ApiEndpointsConfig } from './api-endpoints.model';
|
|||||||
import { BrandingConfig } from './branding.model';
|
import { BrandingConfig } from './branding.model';
|
||||||
import { CompanyConfig } from './company.model';
|
import { CompanyConfig } from './company.model';
|
||||||
import { FeatureFlagsConfig } from './feature-flags.model';
|
import { FeatureFlagsConfig } from './feature-flags.model';
|
||||||
|
import { FooterConfig } from './footer-config.model';
|
||||||
|
import { PlatformLayoutConfig } from './layout.model';
|
||||||
import { LocalizationConfig } from './localization.model';
|
import { LocalizationConfig } from './localization.model';
|
||||||
import { NavigationConfig } from './navigation.model';
|
import { NavigationConfig } from './navigation.model';
|
||||||
import { PageConfig } from './page.model';
|
import { PageConfig } from './page.model';
|
||||||
@@ -10,6 +12,7 @@ import { SeoConfig } from './seo.model';
|
|||||||
import { StaticPagesConfig } from './static-page.model';
|
import { StaticPagesConfig } from './static-page.model';
|
||||||
import { TenantConfig } from './tenant.model';
|
import { TenantConfig } from './tenant.model';
|
||||||
import { ThemeConfig } from './theme.model';
|
import { ThemeConfig } from './theme.model';
|
||||||
|
import { WidgetRegistryConfig } from './widget-registry.model';
|
||||||
|
|
||||||
export interface BootstrapConfig {
|
export interface BootstrapConfig {
|
||||||
schemaVersion: string;
|
schemaVersion: string;
|
||||||
@@ -23,7 +26,10 @@ export interface BootstrapConfig {
|
|||||||
localization: LocalizationConfig;
|
localization: LocalizationConfig;
|
||||||
seo: SeoConfig;
|
seo: SeoConfig;
|
||||||
permissions: PermissionsConfig;
|
permissions: PermissionsConfig;
|
||||||
|
layout?: PlatformLayoutConfig;
|
||||||
navigation: NavigationConfig;
|
navigation: NavigationConfig;
|
||||||
|
footer?: FooterConfig;
|
||||||
pages: PageConfig[];
|
pages: PageConfig[];
|
||||||
staticPages?: StaticPagesConfig;
|
staticPages?: StaticPagesConfig;
|
||||||
|
widgetRegistry?: WidgetRegistryConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/app/shared/models/config/footer-config.model.ts
Normal file
15
src/app/shared/models/config/footer-config.model.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { LocalizedTextContent } from './static-page.model';
|
||||||
|
|
||||||
|
export interface FooterPaymentIconConfig {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterConfig {
|
||||||
|
logoUrl?: string;
|
||||||
|
paymentIcons?: FooterPaymentIconConfig[];
|
||||||
|
copyrightText?: string | LocalizedTextContent;
|
||||||
|
legalPageKeys?: string[];
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ export * from './bootstrap-config.model';
|
|||||||
export * from './branding.model';
|
export * from './branding.model';
|
||||||
export * from './company.model';
|
export * from './company.model';
|
||||||
export * from './feature-flags.model';
|
export * from './feature-flags.model';
|
||||||
|
export * from './footer-config.model';
|
||||||
|
export * from './layout.model';
|
||||||
export * from './localization.model';
|
export * from './localization.model';
|
||||||
export * from './navigation.model';
|
export * from './navigation.model';
|
||||||
export * from './page.model';
|
export * from './page.model';
|
||||||
@@ -13,3 +15,4 @@ export * from './static-page.model';
|
|||||||
export * from './tenant.model';
|
export * from './tenant.model';
|
||||||
export * from './theme.model';
|
export * from './theme.model';
|
||||||
export * from './widget.model';
|
export * from './widget.model';
|
||||||
|
export * from './widget-registry.model';
|
||||||
|
|||||||
6
src/app/shared/models/config/layout.model.ts
Normal file
6
src/app/shared/models/config/layout.model.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type PlatformLayoutType = 'default' | 'sidebar-left' | 'carousel-home' | 'minimal' | string;
|
||||||
|
|
||||||
|
export interface PlatformLayoutConfig {
|
||||||
|
type: PlatformLayoutType;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SectionConfig } from './section.model';
|
import { SectionConfig } from './section.model';
|
||||||
|
import { PlatformLayoutConfig } from './layout.model';
|
||||||
|
|
||||||
export interface PageRouteConfig {
|
export interface PageRouteConfig {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -10,7 +11,7 @@ export interface PageConfig {
|
|||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
route: PageRouteConfig;
|
route: PageRouteConfig;
|
||||||
layout: string;
|
layout: string | PlatformLayoutConfig;
|
||||||
sections: SectionConfig[];
|
sections: SectionConfig[];
|
||||||
seoKey?: string;
|
seoKey?: string;
|
||||||
featureFlag?: string;
|
featureFlag?: string;
|
||||||
|
|||||||
3
src/app/shared/models/config/widget-registry.model.ts
Normal file
3
src/app/shared/models/config/widget-registry.model.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface WidgetRegistryConfig {
|
||||||
|
manifestUrl: string;
|
||||||
|
}
|
||||||
@@ -4,10 +4,19 @@ export interface WidgetActionConfig {
|
|||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WidgetVisibilityConfig {
|
||||||
|
desktop?: boolean;
|
||||||
|
tablet?: boolean;
|
||||||
|
mobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WidgetConfig {
|
export interface WidgetConfig {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
order?: number;
|
||||||
|
padding?: string;
|
||||||
|
visibility?: WidgetVisibilityConfig;
|
||||||
props: Record<string, unknown>;
|
props: Record<string, unknown>;
|
||||||
actions?: Record<string, WidgetActionConfig>;
|
actions?: Record<string, WidgetActionConfig>;
|
||||||
featureFlag?: string;
|
featureFlag?: string;
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, catchError, map, of, shareReplay } from 'rxjs';
|
import { Observable, catchError, map, of, shareReplay, switchMap, take } from 'rxjs';
|
||||||
import { WidgetManifestEntry, WidgetManifestFile } from '../contracts/widget-manifest.contract';
|
import { WidgetManifestEntry, WidgetManifestFile } from '../contracts/widget-manifest.contract';
|
||||||
|
import { ConfigService } from '../../core/config/config.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class WidgetManifestService {
|
export class WidgetManifestService {
|
||||||
private readonly manifestUrl = '/assets/mock/bootstrap/widget-manifest.json';
|
private readonly fallbackManifestUrl = '/assets/mock/bootstrap/widget-manifest.json';
|
||||||
private manifest$?: Observable<WidgetManifestFile>;
|
private readonly manifestByUrl = new Map<string, Observable<WidgetManifestFile>>();
|
||||||
|
|
||||||
constructor(private readonly http: HttpClient) {}
|
constructor(
|
||||||
|
private readonly http: HttpClient,
|
||||||
|
private readonly configService: ConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
getManifest(): Observable<WidgetManifestFile> {
|
getManifest(): Observable<WidgetManifestFile> {
|
||||||
if (!this.manifest$) {
|
return this.resolveManifestUrl().pipe(
|
||||||
this.manifest$ = this.http.get<WidgetManifestFile>(this.manifestUrl).pipe(
|
switchMap((manifestUrl) => {
|
||||||
shareReplay({ bufferSize: 1, refCount: true }),
|
const cached = this.manifestByUrl.get(manifestUrl);
|
||||||
catchError(() => {
|
if (cached) {
|
||||||
this.manifest$ = undefined;
|
return cached;
|
||||||
return of({ widgets: [] });
|
}
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.manifest$;
|
const manifest$ = this.http.get<WidgetManifestFile>(manifestUrl).pipe(
|
||||||
|
shareReplay({ bufferSize: 1, refCount: true }),
|
||||||
|
catchError(() => of({ widgets: [] }))
|
||||||
|
);
|
||||||
|
|
||||||
|
this.manifestByUrl.set(manifestUrl, manifest$);
|
||||||
|
return manifest$;
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getWidgets(): Observable<WidgetManifestEntry[]> {
|
getWidgets(): Observable<WidgetManifestEntry[]> {
|
||||||
@@ -31,4 +40,17 @@ export class WidgetManifestService {
|
|||||||
getWidget(type: string): Observable<WidgetManifestEntry | undefined> {
|
getWidget(type: string): Observable<WidgetManifestEntry | undefined> {
|
||||||
return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type)));
|
return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveManifestUrl(): Observable<string> {
|
||||||
|
const snapshotUrl = this.configService.getBootstrapSnapshot()?.widgetRegistry?.manifestUrl;
|
||||||
|
if (snapshotUrl) {
|
||||||
|
return of(snapshotUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.configService.loadBootstrap().pipe(
|
||||||
|
take(1),
|
||||||
|
map((bootstrap) => bootstrap.widgetRegistry?.manifestUrl || this.fallbackManifestUrl),
|
||||||
|
catchError(() => of(this.fallbackManifestUrl))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,10 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { Observable, map, of } from 'rxjs';
|
import { Observable, map, of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CategoriesWidgetComponent, FooterNavigationWidgetComponent, HeroWidgetComponent, ProductCarouselWidgetComponent } from '../ui';
|
import { CategoriesWidgetComponent, FooterNavigationWidgetComponent, HeroWidgetComponent, ProductCarouselWidgetComponent } from '../ui';
|
||||||
import { RegisteredWidget } from '../contracts/widget-component.contract';
|
import { RegisteredWidget } from '../contracts/widget-component.contract';
|
||||||
import { WidgetRegistryService } from './widget-registry.service';
|
import { WidgetRegistryService } from './widget-registry.service';
|
||||||
|
import { WidgetManifestService } from './widget-manifest.service';
|
||||||
interface WidgetManifestItem {
|
|
||||||
type: string;
|
|
||||||
version: string;
|
|
||||||
componentKey: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WidgetManifest {
|
|
||||||
widgets: WidgetManifestItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const APPROVED_WIDGET_COMPONENTS: Record<string, RegisteredWidget['component']> = {
|
const APPROVED_WIDGET_COMPONENTS: Record<string, RegisteredWidget['component']> = {
|
||||||
'hero': HeroWidgetComponent,
|
'hero': HeroWidgetComponent,
|
||||||
@@ -28,17 +17,15 @@ const APPROVED_WIDGET_COMPONENTS: Record<string, RegisteredWidget['component']>
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class WidgetRegistryBootstrapService {
|
export class WidgetRegistryBootstrapService {
|
||||||
private readonly manifestUrl = '/assets/mock/bootstrap/widget-manifest.json';
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly registry: WidgetRegistryService,
|
private readonly registry: WidgetRegistryService,
|
||||||
private readonly http: HttpClient
|
private readonly widgetManifest: WidgetManifestService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
registerFromManifest(): Observable<void> {
|
registerFromManifest(): Observable<void> {
|
||||||
return this.http.get<WidgetManifest>(this.manifestUrl).pipe(
|
return this.widgetManifest.getWidgets().pipe(
|
||||||
map((manifest) => {
|
map((manifestWidgets) => {
|
||||||
const widgets = (manifest.widgets ?? [])
|
const widgets = manifestWidgets
|
||||||
.filter((item) => item.enabled !== false)
|
.filter((item) => item.enabled !== false)
|
||||||
.map((item): RegisteredWidget | null => {
|
.map((item): RegisteredWidget | null => {
|
||||||
const component = APPROVED_WIDGET_COMPONENTS[item.componentKey];
|
const component = APPROVED_WIDGET_COMPONENTS[item.componentKey];
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { SectionConfig } from '../../shared/models/config';
|
import { SectionConfig } from '../../shared/models/config';
|
||||||
import { CatalogCategoryGridComponent } from '../../features/website/catalog/components/category-grid/category-grid.component';
|
import { CatalogCategoryGridComponent } from '../../features/website/catalog/components/category-grid/category-grid.component';
|
||||||
import { CategoriesWidgetData } from '../contracts/widget-data.contract';
|
import { CategoriesWidgetData } from '../contracts/widget-data.contract';
|
||||||
import { Category } from '../../core/categories/models/category-domain.model';
|
import { Category } from '../../core/categories/models/category-domain.model';
|
||||||
|
import { LanguageService } from '../../services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-categories-widget',
|
selector: 'app-categories-widget',
|
||||||
@@ -36,11 +38,15 @@ import { Category } from '../../core/categories/models/category-domain.model';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class CategoriesWidgetComponent {
|
export class CategoriesWidgetComponent {
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
@Input() section: SectionConfig | null = null;
|
@Input() section: SectionConfig | null = null;
|
||||||
@Input() data: CategoriesWidgetData | null = null;
|
@Input() data: CategoriesWidgetData | null = null;
|
||||||
@Output() categorySelected = new EventEmitter<Category>();
|
@Output() categorySelected = new EventEmitter<Category>();
|
||||||
|
|
||||||
onCategorySelected(category: Category): void {
|
onCategorySelected(category: Category): void {
|
||||||
this.categorySelected.emit(category);
|
this.categorySelected.emit(category);
|
||||||
|
this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`, category.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,6 +170,9 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"layout": {
|
||||||
|
"type": "default"
|
||||||
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"header": [
|
"header": [
|
||||||
{
|
{
|
||||||
@@ -215,6 +218,78 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"paymentIcons": [
|
||||||
|
{
|
||||||
|
"src": "/assets/images/mir-logo.svg",
|
||||||
|
"alt": "MIR",
|
||||||
|
"width": 40,
|
||||||
|
"height": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/images/visa-logo.svg",
|
||||||
|
"alt": "Visa",
|
||||||
|
"width": 40,
|
||||||
|
"height": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/images/mastercard-logo.svg",
|
||||||
|
"alt": "Mastercard",
|
||||||
|
"width": 40,
|
||||||
|
"height": 28
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"copyrightText": {
|
||||||
|
"ru": "© 2026 Marketplace. Все права защищены.",
|
||||||
|
"en": "© 2026 Marketplace. All rights reserved.",
|
||||||
|
"hy": "© 2026 Marketplace. Բոլոր իրավունքները պաշտպանված են:"
|
||||||
|
},
|
||||||
|
"legalPageKeys": ["about-us", "privacy-policy", "terms-of-service"]
|
||||||
|
},
|
||||||
|
"widgetRegistry": {
|
||||||
|
"manifestUrl": "/assets/mock/bootstrap/widget-manifest.json"
|
||||||
|
},
|
||||||
|
"staticPages": {
|
||||||
|
"about-us": {
|
||||||
|
"route": "/about-us",
|
||||||
|
"title": {
|
||||||
|
"ru": "О компании",
|
||||||
|
"en": "About Us",
|
||||||
|
"hy": "Մեր մասին"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"ru": "<h2>О компании</h2><p>Marketplace — мультиарендная B2B commerce-платформа.</p>",
|
||||||
|
"en": "<h2>About Us</h2><p>Marketplace is a multi-tenant B2B commerce platform.</p>",
|
||||||
|
"hy": "<h2>Մեր մասին</h2><p>Marketplace-ը բազմավարձակալ B2B առևտրային հարթակ է։</p>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"privacy-policy": {
|
||||||
|
"route": "/privacy-policy",
|
||||||
|
"title": {
|
||||||
|
"ru": "Политика конфиденциальности",
|
||||||
|
"en": "Privacy Policy",
|
||||||
|
"hy": "Գաղտնիության քաղաքականություն"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"ru": "<h2>Политика конфиденциальности</h2><p>Мы обрабатываем только необходимые данные.</p>",
|
||||||
|
"en": "<h2>Privacy Policy</h2><p>We process only the data required for service operations.</p>",
|
||||||
|
"hy": "<h2>Գաղտնիության քաղաքականություն</h2><p>Մենք մշակում ենք միայն ծառայության համար անհրաժեշտ տվյալները։</p>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"terms-of-service": {
|
||||||
|
"route": "/terms-of-service",
|
||||||
|
"title": {
|
||||||
|
"ru": "Условия использования",
|
||||||
|
"en": "Terms of Service",
|
||||||
|
"hy": "Օգտագործման պայմաններ"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"ru": "<h2>Условия использования</h2><p>Использование платформы регулируется публичной офертой.</p>",
|
||||||
|
"en": "<h2>Terms of Service</h2><p>Platform usage is governed by public offer terms.</p>",
|
||||||
|
"hy": "<h2>Օգտագործման պայմաններ</h2><p>Հարթակի օգտագործումը կարգավորվում է հրապարակային առաջարկի պայմաններով։</p>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"id": "page-home",
|
"id": "page-home",
|
||||||
@@ -224,7 +299,9 @@
|
|||||||
"path": "/",
|
"path": "/",
|
||||||
"exact": true
|
"exact": true
|
||||||
},
|
},
|
||||||
"layout": "default-public",
|
"layout": {
|
||||||
|
"type": "default"
|
||||||
|
},
|
||||||
"seoKey": "home",
|
"seoKey": "home",
|
||||||
"visible": true,
|
"visible": true,
|
||||||
"sections": [
|
"sections": [
|
||||||
@@ -249,6 +326,13 @@
|
|||||||
"id": "widget-hero-main",
|
"id": "widget-hero-main",
|
||||||
"type": "hero",
|
"type": "hero",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"order": 1,
|
||||||
|
"padding": "0.5rem 0",
|
||||||
|
"visibility": {
|
||||||
|
"desktop": true,
|
||||||
|
"tablet": true,
|
||||||
|
"mobile": true
|
||||||
|
},
|
||||||
"visible": true,
|
"visible": true,
|
||||||
"props": {
|
"props": {
|
||||||
"title": "Welcome to Marketplace Platform",
|
"title": "Welcome to Marketplace Platform",
|
||||||
@@ -279,6 +363,13 @@
|
|||||||
"id": "widget-categories-root",
|
"id": "widget-categories-root",
|
||||||
"type": "categories",
|
"type": "categories",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"order": 1,
|
||||||
|
"padding": "0.25rem 0",
|
||||||
|
"visibility": {
|
||||||
|
"desktop": true,
|
||||||
|
"tablet": true,
|
||||||
|
"mobile": true
|
||||||
|
},
|
||||||
"visible": true,
|
"visible": true,
|
||||||
"props": {
|
"props": {
|
||||||
"title": "Categories",
|
"title": "Categories",
|
||||||
@@ -309,6 +400,13 @@
|
|||||||
"id": "widget-featured-products",
|
"id": "widget-featured-products",
|
||||||
"type": "product-collection",
|
"type": "product-collection",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"order": 1,
|
||||||
|
"padding": "0",
|
||||||
|
"visibility": {
|
||||||
|
"desktop": true,
|
||||||
|
"tablet": true,
|
||||||
|
"mobile": true
|
||||||
|
},
|
||||||
"visible": true,
|
"visible": true,
|
||||||
"props": {
|
"props": {
|
||||||
"title": "Featured Products",
|
"title": "Featured Products",
|
||||||
|
|||||||
@@ -147,7 +147,9 @@ a, button, input, textarea, select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
margin: 24px 0;
|
margin: 0;
|
||||||
|
padding-block: clamp(16px, 2.5vw, 32px);
|
||||||
|
animation: section-fade-in 320ms ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -155,6 +157,94 @@ a, button, input, textarea, select {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card,
|
||||||
|
.item-card,
|
||||||
|
.catalog-product-card {
|
||||||
|
transition: transform 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover,
|
||||||
|
.item-card:hover,
|
||||||
|
.catalog-product-card:hover {
|
||||||
|
transform: translateY(-2px) scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease, color 180ms ease, border-color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
border-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--secondary-color);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--secondary-hover);
|
||||||
|
border-color: var(--secondary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: rgba(73, 118, 113, 0.08);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes section-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.grid-2 {
|
.grid-2 {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user