refactor: finalize bootstrap-driven layout and widget runtime

This commit is contained in:
sdarbinyan
2026-07-05 04:17:31 +04:00
parent 6550250d13
commit c901ec1e49
19 changed files with 697 additions and 181 deletions

View File

@@ -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>&copy; {{ currentYear }} {{ brandName }}. {{ 'footer.allRightsReserved' | translate }}</p> <p>
@if (copyrightText()) {
{{ copyrightText() }}
} @else {
&copy; {{ currentYear }} {{ brandName }}. {{ 'footer.allRightsReserved' | translate }}
}
</p>
@if (contactEmail) { @if (contactEmail) {
<a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a> <a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>
} }

View File

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

View 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 '';
}
}

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View 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[];
}

View File

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

View File

@@ -0,0 +1,6 @@
export type PlatformLayoutType = 'default' | 'sidebar-left' | 'carousel-home' | 'minimal' | string;
export interface PlatformLayoutConfig {
type: PlatformLayoutType;
options?: Record<string, unknown>;
}

View File

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

View File

@@ -0,0 +1,3 @@
export interface WidgetRegistryConfig {
manifestUrl: string;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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