feat(builder): add project editor

This commit is contained in:
sdarbinyan
2026-07-10 13:43:53 +04:00
parent 7161a81068
commit e2c8747fcc
50 changed files with 1656 additions and 42 deletions

View File

@@ -39,6 +39,15 @@ const coreRoutes: Routes = [
path: 'search',
loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent)
},
{
path: 'builder',
loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent)
},
{
path: 'project-editor',
redirectTo: 'builder',
pathMatch: 'full'
},
{
path: 'wishlist',
loadComponent: () => import('./features/website/user-experience/wishlist/containers/wishlist-page.component').then(m => m.WishlistPageComponent)

View File

@@ -1,11 +1,11 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { Component, ChangeDetectionStrategy, effect, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { LogoComponent } from '../logo/logo.component';
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { FooterPaymentIcon, FooterResolvedGroup, FooterResolverService } from '../../core/config/footer-resolver.service';
import { ConfigService } from '../../core/config/config.service';
@Component({
selector: 'app-footer',
@@ -19,22 +19,29 @@ export class FooterComponent {
readonly paymentIcons = signal<FooterPaymentIcon[]>([]);
readonly copyrightText = signal('');
private readonly configService = inject(ConfigService);
constructor(
private readonly uiRuntime: UiRuntimeFacade,
private readonly footerResolver: FooterResolverService
) {
this.footerResolver.resolveFooterModel().pipe(take(1)).subscribe({
next: model => {
this.footerGroups.set(model.groups);
this.paymentIcons.set(model.paymentIcons);
this.copyrightText.set(model.copyrightText);
},
error: () => {
effect(() => {
this.configService.bootstrapRevision();
const bootstrap = this.configService.getBootstrapSnapshot();
if (!bootstrap) {
this.footerGroups.set([]);
this.paymentIcons.set([]);
this.copyrightText.set('');
return;
}
const model = this.footerResolver.resolveFooterModelFromBootstrap(bootstrap);
this.footerGroups.set(model.groups);
this.paymentIcons.set(model.paymentIcons);
this.copyrightText.set(model.copyrightText);
});
this.configService.loadBootstrap().subscribe();
}
currentYear = new Date().getFullYear();

View File

@@ -2,9 +2,11 @@
<header class="platform-header">
<div class="platform-header-container">
<!-- Logo -->
<a [attr.href]="homeUrl" class="platform-logo" (click)="navigateHome($event)">
<app-logo />
</a>
@if (headerConfig().showLogo) {
<a [attr.href]="homeUrl" class="platform-logo" (click)="navigateHome($event)">
<app-logo />
</a>
}
<!-- Navigation Buttons (desktop) -->
<nav class="platform-nav">
@@ -13,11 +15,17 @@
(click)="closeMenu()" class="platform-nav-btn platform-nav-btn-left">
{{ 'header.home' | translate }}
</a>
@if (headerConfig().showCategories) {
<button type="button" (click)="navigateToCatalog()" class="platform-nav-btn platform-nav-btn-left">
{{ 'header.catalog' | translate }}
</button>
}
<!-- TODO(CMS): Render backend-configured header content links here. -->
</div>
</nav>
<!-- Search Box (desktop) -->
@if (headerConfig().showSearch) {
<div class="platform-search-wrapper">
<div class="platform-search-box">
<svg class="platform-search-icon" width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -27,27 +35,30 @@
<input type="text" [placeholder]="'header.searchPlaceholder' | translate" class="platform-search-input" (click)="navigateToSearch()" readonly />
</div>
</div>
}
<!-- Search Icon (mobile only) -->
@if (headerConfig().showSearch) {
<button class="platform-search-mobile" (click)="navigateToSearch()" [attr.aria-label]="'header.search' | translate">
<svg width="22" height="22" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12Z" fill="#1e3c38" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.2929 18.2929C18.6834 17.9024 19.3166 17.9024 19.7071 18.2929L25.7071 24.2929C26.0976 24.6834 26.0976 25.3166 25.7071 25.7071C25.3166 26.0976 24.6834 26.0976 24.2929 25.7071L18.2929 19.7071C17.9024 19.3166 17.9024 18.6834 18.2929 18.2929Z" fill="#1e3c38" />
</svg>
</button>
}
<!-- Right Actions -->
<div class="platform-actions">
@if (userExperienceConfig.wishlist.enabled) {
@if (headerConfig().showWishlist && userExperienceConfig().wishlist.enabled) {
<button type="button" class="platform-ux-btn" (click)="navigateToWishlist()" [attr.aria-label]="'header.wishlist' | translate">
<span class="platform-ux-icon"></span>
@if (userExperienceConfig.wishlist.headerBadgeEnabled && wishlistCount() > 0) {
@if (userExperienceConfig().wishlist.headerBadgeEnabled && wishlistCount() > 0) {
<span class="platform-ux-badge">{{ wishlistCount() }}</span>
}
</button>
}
@if (userExperienceConfig.compare.enabled) {
@if (headerConfig().showCompare && userExperienceConfig().compare.enabled) {
<button type="button" class="platform-ux-btn" (click)="navigateToCompare()" [attr.aria-label]="'header.compare' | translate">
<span class="platform-ux-icon"></span>
@if (compareCount() > 0) {
@@ -57,6 +68,7 @@
}
<!-- Cart Button -->
@if (headerConfig().showCart) {
<a [routerLink]="'/cart' | langRoute" routerLinkActive="platform-cart-active" class="platform-cart-btn" (click)="closeMenu()">
<span class="platform-cart-icon">
<svg width="32" height="24" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -72,16 +84,21 @@
<span class="platform-cart-total">{{ formatCartTotal(cartTotal()) }}</span>
}
</a>
}
<!-- Region Selector (desktop only) -->
@if (headerConfig().showRegion) {
<div class="platform-region-selector platform-lang-desktop">
<app-region-selector />
</div>
}
<!-- Language Selector (desktop only) -->
@if (headerConfig().showLanguages) {
<div class="platform-lang-selector platform-lang-desktop">
<app-language-selector />
</div>
}
<!-- Mobile Menu Toggle -->
<button class="platform-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? ('header.closeMenu' | translate) : ('header.openMenu' | translate)" [attr.aria-expanded]="menuOpen">
@@ -111,6 +128,7 @@
</svg>
</a>
@if (headerConfig().showCategories) {
<a (click)="navigateToCatalog()" class="platform-mobile-item" style="cursor: pointer;">
<svg width="24" height="24" viewBox="0 0 31 31" fill="none">
<path d="M1.9375 4.84375C1.9375 3.23867 3.23867 1.9375 4.84375 1.9375L10.6562 1.9375C12.2613 1.9375 13.5625 3.23867 13.5625 4.84375V10.6562C13.5625 12.2613 12.2613 13.5625 10.6562 13.5625H4.84375C3.23867 13.5625 1.9375 12.2613 1.9375 10.6562L1.9375 4.84375ZM4.84375 3.875C4.30872 3.875 3.875 4.30872 3.875 4.84375V10.6562C3.875 11.1913 4.30872 11.625 4.84375 11.625H10.6562C11.1913 11.625 11.625 11.1913 11.625 10.6562V4.84375C11.625 4.30872 11.1913 3.875 10.6562 3.875H4.84375ZM17.4375 4.84375C17.4375 3.23867 18.7387 1.9375 20.3438 1.9375L26.1562 1.9375C27.7613 1.9375 29.0625 3.23867 29.0625 4.84375V10.6562C29.0625 12.2613 27.7613 13.5625 26.1562 13.5625H20.3438C18.7387 13.5625 17.4375 12.2613 17.4375 10.6562V4.84375ZM20.3438 3.875C19.8087 3.875 19.375 4.30872 19.375 4.84375V10.6562C19.375 11.1913 19.8087 11.625 20.3438 11.625H26.1562C26.6913 11.625 27.125 11.1913 27.125 10.6562V4.84375C27.125 4.30872 26.6913 3.875 26.1562 3.875H20.3438ZM1.9375 20.3438C1.9375 18.7387 3.23867 17.4375 4.84375 17.4375H10.6562C12.2613 17.4375 13.5625 18.7387 13.5625 20.3438V26.1562C13.5625 27.7613 12.2613 29.0625 10.6562 29.0625H4.84375C3.23867 29.0625 1.9375 27.7613 1.9375 26.1562L1.9375 20.3438ZM4.84375 19.375C4.30872 19.375 3.875 19.8087 3.875 20.3438V26.1562C3.875 26.6913 4.30872 27.125 4.84375 27.125H10.6562C11.1913 27.125 11.625 26.6913 11.625 26.1562V20.3438C11.625 19.8087 11.1913 19.375 10.6562 19.375H4.84375ZM17.4375 20.3438C17.4375 18.7387 18.7387 17.4375 20.3438 17.4375H26.1562C27.7613 17.4375 29.0625 18.7387 29.0625 20.3438V26.1562C29.0625 27.7613 27.7613 29.0625 26.1562 29.0625H20.3438C18.7387 29.0625 17.4375 27.7613 17.4375 26.1562V20.3438ZM20.3438 19.375C19.8087 19.375 19.375 19.8087 19.375 20.3438V26.1562C19.375 26.6913 19.8087 27.125 20.3438 27.125H26.1562C26.6913 27.125 27.125 26.6913 27.125 26.1562V20.3438C27.125 19.8087 26.6913 19.375 26.1562 19.375H20.3438Z" fill="#497671" />
@@ -120,16 +138,21 @@
<path d="M1 1L7 7L1 13" stroke="#697777" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
}
<!-- TODO(CMS): Render backend-configured mobile content links here. -->
<div class="platform-mobile-controls">
@if (headerConfig().showRegion) {
<div class="platform-mobile-lang">
<app-region-selector />
</div>
}
@if (headerConfig().showLanguages) {
<div class="platform-mobile-lang">
<app-language-selector />
</div>
}
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, Renderer2, inject, DOCUMENT } from '@angular/core';
import { Component, ChangeDetectionStrategy, Renderer2, inject, DOCUMENT, computed } from '@angular/core';
import { Router, RouterLink, RouterLinkActive } from '@angular/router';
import { CartService } from '../../services/cart.service';
import { LanguageService } from '../../services/language.service';
@@ -10,7 +10,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
import { UserExperienceFacade } from '../../facades/platform/user-experience.facade';
import { ConfigService } from '../../core/config/config.service';
import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
@Component({
selector: 'app-header',
@@ -33,7 +33,8 @@ export class HeaderComponent {
readonly wishlistCount = this.uxFacade.wishlistCount;
readonly compareCount = this.uxFacade.compareCount;
readonly userExperienceConfig = this.resolveUserExperienceConfig();
readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig());
readonly headerConfig = computed(() => this.resolveHeaderConfig());
constructor(private cartService: CartService, private router: Router) {
this.cartItemCount = this.cartService.itemCount;
@@ -127,6 +128,7 @@ export class HeaderComponent {
}
private resolveUserExperienceConfig() {
this.configService.bootstrapRevision();
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience ?? {};
return {
@@ -142,6 +144,16 @@ export class HeaderComponent {
}
};
}
private resolveHeaderConfig() {
this.configService.bootstrapRevision();
const raw = (this.configService.getBootstrapSnapshot() as any)?.header ?? {};
return {
...DEFAULT_HEADER_CONFIG,
...raw,
};
}
}

View File

@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { Injectable, inject, signal } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError, shareReplay, tap } from 'rxjs/operators';
import { BootstrapConfig } from '../../shared/models/config';
import { CONFIG_PROVIDER } from './config-provider.token';
@@ -10,12 +10,20 @@ export class ConfigService {
private bootstrapSnapshot: BootstrapConfig | null = null;
private bootstrap$?: Observable<BootstrapConfig>;
private readonly revisionState = signal(0);
readonly bootstrapRevision = this.revisionState.asReadonly();
loadBootstrap(forceRefresh: boolean = false): Observable<BootstrapConfig> {
if (this.bootstrapSnapshot && !forceRefresh && this.bootstrap$) {
return this.bootstrap$;
}
if (!this.bootstrap$ || forceRefresh) {
this.bootstrap$ = this.provider.loadBootstrap().pipe(
tap(config => {
this.bootstrapSnapshot = config;
this.revisionState.update(value => value + 1);
}),
shareReplay(1),
catchError(error => {
@@ -32,4 +40,11 @@ export class ConfigService {
getBootstrapSnapshot(): BootstrapConfig | null {
return this.bootstrapSnapshot;
}
applyBootstrapOverride(next: BootstrapConfig): void {
const cloned = JSON.parse(JSON.stringify(next)) as BootstrapConfig;
this.bootstrapSnapshot = cloned;
this.bootstrap$ = of(cloned);
this.revisionState.update(value => value + 1);
}
}

View File

@@ -47,14 +47,18 @@ export class FooterResolverService {
resolveFooterModel(): Observable<FooterResolvedModel> {
return this.configService.loadBootstrap().pipe(
map((bootstrap) => ({
groups: this.resolveFooterGroups(bootstrap),
paymentIcons: this.resolvePaymentIcons(bootstrap.footer),
copyrightText: this.resolveCopyrightText(bootstrap)
}))
map((bootstrap) => this.resolveFooterModelFromBootstrap(bootstrap))
);
}
resolveFooterModelFromBootstrap(bootstrap: BootstrapConfig): FooterResolvedModel {
return {
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();

View File

@@ -5,6 +5,7 @@ import { BrandingEngineService } from '../../theme/runtime/branding-engine.servi
import { ThemeEngineService } from '../../theme/runtime/theme-engine.service';
import { WidgetRegistryBootstrapService } from '../../widgets/registry/widget-registry.bootstrap.service';
import { PlatformRuntimeStateService } from './platform-runtime-state.service';
import { BootstrapConfig } from '../../shared/models/config';
@Injectable({ providedIn: 'root' })
export class PlatformRuntimeService {
@@ -55,4 +56,22 @@ export class PlatformRuntimeService {
}
});
}
reloadFromBootstrap(bootstrap: BootstrapConfig): void {
this.configService.applyBootstrapOverride(bootstrap);
this.widgetRegistryBootstrap.registerFromManifest().subscribe({
next: () => this.applyRuntimeBootstrap(bootstrap),
error: () => this.applyRuntimeBootstrap(bootstrap),
});
}
private applyRuntimeBootstrap(bootstrap: BootstrapConfig): void {
this.themeEngine.applyTheme(bootstrap.theme);
this.brandingEngine.applyBranding(bootstrap.branding);
this.runtimeState.markInitialized({
localization: bootstrap.localization,
featureFlags: bootstrap.featureFlags,
permissions: bootstrap.permissions
});
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { Injectable, effect, signal } from '@angular/core';
import { ConfigService } from '../../core/config/config.service';
import { BootstrapConfig } from '../../shared/models/config';
interface UiRuntimeState {
marketplaceName: string;
@@ -21,17 +21,25 @@ export class UiRuntimeFacade {
});
constructor(private readonly configService: ConfigService) {
this.configService.loadBootstrap().pipe(take(1)).subscribe({
next: (bootstrap) => {
this.state.set({
marketplaceName: bootstrap.branding.brandName,
marketplaceDisplayName: bootstrap.branding.brandName,
logoUrl: bootstrap.branding.logoUrl,
contactEmail: bootstrap.branding.supportEmail ?? '',
themeId: bootstrap.theme.themeId
});
effect(() => {
this.configService.bootstrapRevision();
const bootstrap = this.configService.getBootstrapSnapshot();
if (bootstrap) {
this.reloadFromBootstrap(bootstrap);
}
});
this.configService.loadBootstrap().subscribe();
}
reloadFromBootstrap(bootstrap: BootstrapConfig): void {
this.state.set({
marketplaceName: bootstrap.branding.brandName,
marketplaceDisplayName: bootstrap.branding.brandName,
logoUrl: bootstrap.branding.logoUrl,
contactEmail: bootstrap.branding.supportEmail ?? bootstrap.company?.contacts?.email ?? '',
themeId: bootstrap.theme.themeId
});
}
marketplaceName(): string {

View File

@@ -0,0 +1,5 @@
<nav class="editor-nav" [attr.aria-label]="'builder.title' | translate">
@for (section of sections; track section.id) {
<button type="button" [class.active]="active === section.id" (click)="activeChange.emit(section.id)">{{ section.label | translate }}</button>
}
</nav>

View File

@@ -0,0 +1,22 @@
.editor-nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button {
min-height: 40px;
border-radius: 999px;
border: 1px solid var(--border-color, #d3dad9);
background: #fff;
color: #1e3c38;
padding: 0 14px;
font-weight: 700;
cursor: pointer;
}
button.active {
border-color: #497671;
background: #497671;
color: #fff;
}

View File

@@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { ProjectEditorSectionId } from '../models/project-editor.model';
@Component({
selector: 'app-project-editor-nav',
standalone: true,
imports: [TranslatePipe],
templateUrl: './project-editor-nav.component.html',
styleUrls: ['./project-editor-nav.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorNavComponent {
@Input() active: ProjectEditorSectionId = 'general';
@Output() activeChange = new EventEmitter<ProjectEditorSectionId>();
readonly sections: Array<{ id: ProjectEditorSectionId; label: string }> = [
{ id: 'general', label: 'builder.general' },
{ id: 'branding', label: 'builder.branding' },
{ id: 'theme', label: 'builder.theme' },
{ id: 'header', label: 'builder.header' },
{ id: 'footer', label: 'builder.footer' },
{ id: 'homepage', label: 'builder.homepage' },
{ id: 'widgets', label: 'builder.widgets' },
{ id: 'features', label: 'builder.marketplaceFeatures' },
{ id: 'preview', label: 'builder.preview' },
];
}

View File

@@ -0,0 +1,106 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { BootstrapConfig, DEFAULT_CATALOG_CONFIG, DEFAULT_HEADER_CONFIG, DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../shared/models/config';
import { ConfigService } from '../../../core/config/config.service';
import { ProjectEditorIoService } from '../services/project-editor-io.service';
import { ProjectEditorPreviewService } from '../services/project-editor-preview.service';
import { ProjectEditorState } from '../models/project-editor.model';
@Injectable({ providedIn: 'root' })
export class ProjectEditorFacade {
private readonly configService = inject(ConfigService);
private readonly ioService = inject(ProjectEditorIoService);
private readonly previewService = inject(ProjectEditorPreviewService);
private readonly state = signal<ProjectEditorState>({
bootstrap: null,
importError: null,
activeSection: 'general',
});
readonly bootstrap = computed(() => this.state().bootstrap);
readonly importError = computed(() => this.state().importError);
readonly activeSection = computed(() => this.state().activeSection);
readonly homepagePage = computed(() => this.bootstrap()?.pages.find(page => page.key === 'home' || page.route.path === '/') ?? null);
readonly homepageWidgets = computed(() => this.homepagePage()?.sections.flatMap(section => section.widgets.map(widget => ({ sectionId: section.id, sectionType: section.type, widget }))) ?? []);
loadBootstrap(): void {
this.configService.loadBootstrap(true).pipe(take(1)).subscribe({
next: config => this.state.update(current => ({ ...current, bootstrap: this.normalize(JSON.parse(JSON.stringify(config)) as BootstrapConfig), importError: null })),
error: () => this.state.update(current => ({ ...current, bootstrap: null, importError: 'builder.importError' })),
});
}
updateBootstrap(updater: (current: BootstrapConfig) => BootstrapConfig): void {
const current = this.state().bootstrap;
if (!current) {
return;
}
this.state.update(state => ({
...state,
bootstrap: this.normalize(updater(JSON.parse(JSON.stringify(current)) as BootstrapConfig)),
}));
}
exportBootstrap(): string {
const current = this.state().bootstrap;
return current ? this.ioService.exportBootstrap(current) : '';
}
importBootstrap(raw: string): void {
try {
const imported = this.normalize(this.ioService.importBootstrap(raw));
this.state.update(current => ({ ...current, bootstrap: imported, importError: null }));
} catch {
this.state.update(current => ({ ...current, importError: 'builder.importError' }));
}
}
preview(): void {
const current = this.state().bootstrap;
if (!current) {
return;
}
this.previewService.preview(current);
}
setActiveSection(activeSection: ProjectEditorState['activeSection']): void {
this.state.update(current => ({ ...current, activeSection }));
}
private normalize(config: BootstrapConfig): BootstrapConfig {
return {
...config,
header: {
...DEFAULT_HEADER_CONFIG,
...(config.header ?? {})
},
catalog: {
...DEFAULT_CATALOG_CONFIG,
...(config.catalog ?? {})
},
productPage: {
...DEFAULT_PRODUCT_PAGE_CONFIG,
...(config.productPage ?? {})
},
userExperience: {
...DEFAULT_USER_EXPERIENCE_CONFIG,
...(config.userExperience ?? {})
},
localization: {
...config.localization,
supportedLocales: config.localization?.supportedLocales?.length ? config.localization.supportedLocales : config.tenant.supportedLocales,
defaultLocale: config.localization?.defaultLocale || config.tenant.defaultLocale,
currencyByLocale: config.localization?.currencyByLocale ?? {},
dictionaries: config.localization?.dictionaries ?? []
},
pages: [...(config.pages ?? [])].sort((left, right) => {
const leftOrder = Math.min(...left.sections.map(section => section.order));
const rightOrder = Math.min(...right.sections.map(section => section.order));
return leftOrder - rightOrder;
})
};
}
}

View File

@@ -0,0 +1,36 @@
import { BootstrapConfig, HeaderConfig } from '../../../shared/models/config';
export type ProjectEditorSectionId =
| 'general'
| 'branding'
| 'theme'
| 'header'
| 'footer'
| 'homepage'
| 'widgets'
| 'features'
| 'preview';
export interface ProjectEditorState {
bootstrap: BootstrapConfig | null;
importError: string | null;
activeSection: ProjectEditorSectionId;
}
export interface ProjectEditorWidgetPreset {
id: string;
type: string;
label: string;
}
export const DEFAULT_EDITOR_HEADER_CONFIG: Required<HeaderConfig> = {
showLogo: true,
showSearch: true,
showCategories: true,
showLanguages: true,
showCart: true,
showProfile: false,
showWishlist: true,
showCompare: true,
showRegion: true,
};

View File

@@ -0,0 +1,29 @@
<main class="project-editor-page">
<header class="project-editor-hero">
<div>
<h1>{{ 'builder.title' | translate }}</h1>
<p>{{ 'builder.subtitle' | translate }}</p>
</div>
<app-project-editor-nav [active]="activeSection()" (activeChange)="facade.setActiveSection($event)" />
</header>
@if (!bootstrap()) {
<section class="project-editor-empty">
<p>{{ 'common.loading' | translate }}</p>
</section>
} @else {
<section class="project-editor-stack">
@switch (activeSection()) {
@case ('general') { <app-project-editor-general-section /> }
@case ('branding') { <app-project-editor-branding-section /> }
@case ('theme') { <app-project-editor-theme-section /> }
@case ('header') { <app-project-editor-header-section /> }
@case ('footer') { <app-project-editor-footer-section /> }
@case ('homepage') { <app-project-editor-homepage-section /> }
@case ('widgets') { <app-project-editor-widgets-section /> }
@case ('features') { <app-project-editor-features-section /> }
@case ('preview') { <app-project-editor-preview-section /> }
}
</section>
}
</main>

View File

@@ -0,0 +1,38 @@
.project-editor-page {
max-width: 1240px;
margin: 0 auto;
padding: 24px;
display: grid;
gap: 18px;
}
.project-editor-hero {
display: grid;
gap: 14px;
}
.project-editor-hero h1,
.project-editor-hero p {
margin: 0;
}
.project-editor-hero p {
color: #697777;
}
.project-editor-stack {
display: grid;
gap: 16px;
}
.project-editor-empty {
min-height: 240px;
display: grid;
place-items: center;
}
@media (max-width: 720px) {
.project-editor-page {
padding: 16px;
}
}

View File

@@ -0,0 +1,43 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { ProjectEditorNavComponent } from '../components/project-editor-nav.component';
import { ProjectEditorGeneralSectionComponent } from '../sections/general-section.component';
import { ProjectEditorBrandingSectionComponent } from '../sections/branding-section.component';
import { ProjectEditorThemeSectionComponent } from '../sections/theme-section.component';
import { ProjectEditorHeaderSectionComponent } from '../sections/header-section.component';
import { ProjectEditorFooterSectionComponent } from '../sections/footer-section.component';
import { ProjectEditorHomepageSectionComponent } from '../sections/homepage-section.component';
import { ProjectEditorWidgetsSectionComponent } from '../sections/widgets-section.component';
import { ProjectEditorFeaturesSectionComponent } from '../sections/features-section.component';
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-project-editor-page',
standalone: true,
imports: [
TranslatePipe,
ProjectEditorNavComponent,
ProjectEditorGeneralSectionComponent,
ProjectEditorBrandingSectionComponent,
ProjectEditorThemeSectionComponent,
ProjectEditorHeaderSectionComponent,
ProjectEditorFooterSectionComponent,
ProjectEditorHomepageSectionComponent,
ProjectEditorWidgetsSectionComponent,
ProjectEditorFeaturesSectionComponent,
ProjectEditorPreviewSectionComponent,
],
templateUrl: './project-editor-page.component.html',
styleUrls: ['./project-editor-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorPageComponent {
readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
readonly activeSection = this.facade.activeSection;
constructor() {
this.facade.loadBootstrap();
}
}

View File

@@ -0,0 +1,23 @@
@if (bootstrap(); as bootstrap) {
<section class="editor-section-card">
<h2>{{ 'builder.branding' | translate }}</h2>
<div class="editor-grid two">
<label>
<span>{{ 'builder.logo' | translate }}</span>
<input type="text" [ngModel]="bootstrap.branding.logoUrl" (ngModelChange)="updateField('logoUrl', $event)" />
</label>
<label>
<span>{{ 'builder.smallLogo' | translate }}</span>
<input type="text" [ngModel]="bootstrap.branding.logoCompactUrl || ''" (ngModelChange)="updateField('logoCompactUrl', $event)" />
</label>
<label>
<span>{{ 'builder.favicon' | translate }}</span>
<input type="text" [ngModel]="bootstrap.branding.faviconUrl" (ngModelChange)="updateField('faviconUrl', $event)" />
</label>
<label>
<span>{{ 'builder.marketplaceTitle' | translate }}</span>
<input type="text" [ngModel]="bootstrap.seo.default.title" (ngModelChange)="updateMarketplaceTitle($event)" />
</label>
</div>
</section>
}

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-project-editor-branding-section',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './branding-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorBrandingSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
updateField<K extends 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'brandName'>(key: K, value: string): void {
this.facade.updateBootstrap(current => ({
...current,
branding: { ...current.branding, [key]: value }
}));
}
updateMarketplaceTitle(value: string): void {
this.facade.updateBootstrap(current => ({
...current,
seo: { ...current.seo, default: { ...current.seo.default, title: value } }
}));
}
}

View File

@@ -0,0 +1,16 @@
@if (bootstrap(); as bootstrap) {
<section class="editor-section-card">
<h2>{{ 'builder.marketplaceFeatures' | translate }}</h2>
<div class="editor-grid three toggles">
<label class="toggle-row"><input type="checkbox" [checked]="bootstrap.featureFlags.wishlist" (change)="toggleFeature('wishlist', $any($event.target).checked)" /><span>{{ 'builder.showWishlist' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="bootstrap.featureFlags.compare" (change)="toggleFeature('compare', $any($event.target).checked)" /><span>{{ 'builder.showCompare' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="bootstrap.featureFlags.reviews" (change)="toggleFeature('reviews', $any($event.target).checked)" /><span>{{ 'builder.reviews' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="bootstrap.productPage?.questions?.enabled !== false" (change)="toggleProductFeature('questions', $any($event.target).checked)" /><span>{{ 'builder.questions' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="!!bootstrap.featureFlags.comments" (change)="toggleFeature('comments', $any($event.target).checked)" /><span>{{ 'builder.comments' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="!!bootstrap.featureFlags.recommendations" (change)="toggleFeature('recommendations', $any($event.target).checked)" /><span>{{ 'builder.recommendations' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="bootstrap.userExperience?.recentlyViewed?.enabled !== false" (change)="toggleUserExperience('recentlyViewed', $any($event.target).checked)" /><span>{{ 'builder.recentlyViewed' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="bootstrap.catalog?.suggestionsEnabled !== false" (change)="toggleCatalog('suggestionsEnabled', $any($event.target).checked)" /><span>{{ 'builder.searchSuggestions' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="bootstrap.catalog?.searchHistoryEnabled !== false" (change)="toggleCatalog('searchHistoryEnabled', $any($event.target).checked)" /><span>{{ 'builder.searchHistory' | translate }}</span></label>
</div>
</section>
}

View File

@@ -0,0 +1,54 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-project-editor-features-section',
standalone: true,
imports: [TranslatePipe],
templateUrl: './features-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorFeaturesSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
toggleFeature(key: string, checked: boolean): void {
this.facade.updateBootstrap(current => ({ ...current, featureFlags: { ...current.featureFlags, [key]: checked } }));
}
toggleRecentViewed(checked: boolean): void {
this.facade.updateBootstrap(current => ({ ...current, userExperience: { ...current.userExperience, recentlyViewed: { ...current.userExperience!, recentlyViewed: undefined } as any } }));
}
toggleUserExperience(path: 'recentlyViewed' | 'wishlist' | 'compare', checked: boolean): void {
this.facade.updateBootstrap(current => ({
...current,
userExperience: {
...current.userExperience,
[path]: {
...(current.userExperience as any)?.[path],
enabled: checked
}
}
}));
}
toggleCatalog(key: 'suggestionsEnabled' | 'searchHistoryEnabled', checked: boolean): void {
this.facade.updateBootstrap(current => ({ ...current, catalog: { ...current.catalog, [key]: checked } }));
}
toggleProductFeature(section: 'reviews' | 'questions', checked: boolean): void {
this.facade.updateBootstrap(current => ({
...current,
productPage: {
...current.productPage,
[section]: {
...(current.productPage as any)?.[section],
enabled: checked
}
}
}));
}
}

View File

@@ -0,0 +1,15 @@
@if (bootstrap(); as bootstrap) {
<section class="editor-section-card">
<h2>{{ 'builder.footer' | translate }}</h2>
<div class="editor-grid two">
<label><span>{{ 'builder.companyName' | translate }}</span><input type="text" [ngModel]="bootstrap.company.companyName" (ngModelChange)="updateCompanyName($event)" /></label>
<label><span>{{ 'builder.address' | translate }}</span><input type="text" [ngModel]="bootstrap.company.address.street || ''" (ngModelChange)="updateAddress($event)" /></label>
<label><span>{{ 'builder.phone' | translate }}</span><input type="text" [ngModel]="bootstrap.company.contacts.phone || ''" (ngModelChange)="updatePhone($event)" /></label>
<label><span>{{ 'builder.email' | translate }}</span><input type="text" [ngModel]="bootstrap.company.contacts.email" (ngModelChange)="updateEmail($event)" /></label>
<label class="full"><span>{{ 'builder.copyright' | translate }}</span><input type="text" [ngModel]="copyrightValue()" (ngModelChange)="updateCopyright($event)" /></label>
<label class="full"><span>{{ 'builder.paymentIcons' | translate }}</span><textarea rows="4" [ngModel]="paymentIconsValue()" (ngModelChange)="updatePaymentIcons($event)"></textarea></label>
<label class="full"><span>{{ 'builder.socialLinks' | translate }}</span><textarea rows="4" [ngModel]="socialLinksValue()" (ngModelChange)="updateSocialLinks($event)"></textarea></label>
<label class="full"><span>{{ 'builder.staticPages' | translate }}</span><input type="text" [ngModel]="staticPagesValue()" (ngModelChange)="updateStaticPages($event)" /></label>
</div>
</section>
}

View File

@@ -0,0 +1,48 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-project-editor-footer-section',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './footer-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorFooterSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
readonly paymentIconsValue = computed(() => (this.bootstrap()?.footer?.paymentIcons ?? []).map(icon => `${icon.src}|${icon.alt}`).join('\n'));
readonly socialLinksValue = computed(() => (this.bootstrap()?.footer?.socialLinks ?? []).map(link => `${link.id}|${link.label}|${link.url}`).join('\n'));
readonly staticPagesValue = computed(() => (this.bootstrap()?.footer?.staticPageKeys ?? this.bootstrap()?.footer?.legalPageKeys ?? []).join(', '));
readonly copyrightValue = computed(() => {
const value = this.bootstrap()?.footer?.copyrightText;
return typeof value === 'string' ? value : '';
});
updateCompanyName(value: string): void { this.facade.updateBootstrap(current => ({ ...current, company: { ...current.company, companyName: value } })); }
updateAddress(value: string): void { this.facade.updateBootstrap(current => ({ ...current, company: { ...current.company, address: { ...current.company.address, street: value } } })); }
updatePhone(value: string): void { this.facade.updateBootstrap(current => ({ ...current, company: { ...current.company, contacts: { ...current.company.contacts, phone: value } }, branding: { ...current.branding, supportPhone: value } })); }
updateEmail(value: string): void { this.facade.updateBootstrap(current => ({ ...current, company: { ...current.company, contacts: { ...current.company.contacts, email: value } }, branding: { ...current.branding, supportEmail: value } })); }
updateCopyright(value: string): void { this.facade.updateBootstrap(current => ({ ...current, footer: { ...current.footer, copyrightText: value } })); }
updatePaymentIcons(value: string): void {
const paymentIcons = value.split('\n').map(line => line.trim()).filter(Boolean).map((line, index) => {
const [src, alt] = line.split('|');
return { src: src?.trim() ?? '', alt: alt?.trim() ?? `icon-${index + 1}` };
});
this.facade.updateBootstrap(current => ({ ...current, footer: { ...current.footer, paymentIcons } }));
}
updateSocialLinks(value: string): void {
const socialLinks = value.split('\n').map(line => line.trim()).filter(Boolean).map((line, index) => {
const [id, label, url] = line.split('|');
return { id: id?.trim() || `social-${index + 1}`, label: label?.trim() || '', url: url?.trim() || '' };
});
this.facade.updateBootstrap(current => ({ ...current, footer: { ...current.footer, socialLinks } }));
}
updateStaticPages(value: string): void {
const staticPageKeys = value.split(',').map(item => item.trim()).filter(Boolean);
this.facade.updateBootstrap(current => ({ ...current, footer: { ...current.footer, staticPageKeys, legalPageKeys: staticPageKeys } }));
}
}

View File

@@ -0,0 +1,27 @@
@if (bootstrap(); as bootstrap) {
<section class="editor-section-card">
<h2>{{ 'builder.general' | translate }}</h2>
<div class="editor-grid two">
<label>
<span>{{ 'builder.marketplaceName' | translate }}</span>
<input type="text" [ngModel]="bootstrap.branding.brandName" (ngModelChange)="updateMarketplaceName($event)" />
</label>
<label>
<span>{{ 'builder.domain' | translate }}</span>
<input type="text" [ngModel]="bootstrap.tenant.host" (ngModelChange)="updateDomain($event)" />
</label>
<label class="full">
<span>{{ 'builder.descriptionLabel' | translate }}</span>
<textarea rows="3" [ngModel]="bootstrap.seo.default.description" (ngModelChange)="updateDescription($event)"></textarea>
</label>
<label>
<span>{{ 'builder.defaultLanguage' | translate }}</span>
<input type="text" [ngModel]="bootstrap.localization.defaultLocale" (ngModelChange)="updateDefaultLanguage($event)" />
</label>
<label>
<span>{{ 'builder.supportedLanguages' | translate }}</span>
<input type="text" [ngModel]="languagesValue()" (ngModelChange)="updateSupportedLanguages($event)" />
</label>
</div>
</section>
}

View File

@@ -0,0 +1,63 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-project-editor-general-section',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './general-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorGeneralSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
readonly languagesValue = computed(() => (this.bootstrap()?.localization.supportedLocales ?? []).join(', '));
updateMarketplaceName(value: string): void {
this.facade.updateBootstrap(current => ({
...current,
branding: { ...current.branding, brandName: value },
tenant: { ...current.tenant, name: value }
}));
}
updateDomain(value: string): void {
this.facade.updateBootstrap(current => ({
...current,
tenant: { ...current.tenant, host: value, websiteBaseUrl: `https://${value}` }
}));
}
updateDescription(value: string): void {
this.facade.updateBootstrap(current => ({
...current,
seo: {
...current.seo,
default: {
...current.seo.default,
description: value
}
}
}));
}
updateDefaultLanguage(value: string): void {
this.facade.updateBootstrap(current => ({
...current,
tenant: { ...current.tenant, defaultLocale: value },
localization: { ...current.localization, defaultLocale: value }
}));
}
updateSupportedLanguages(value: string): void {
const locales = value.split(',').map(item => item.trim()).filter(Boolean);
this.facade.updateBootstrap(current => ({
...current,
tenant: { ...current.tenant, supportedLocales: locales },
localization: { ...current.localization, supportedLocales: locales }
}));
}
}

View File

@@ -0,0 +1,13 @@
@if (bootstrap(); as bootstrap) {
<section class="editor-section-card">
<h2>{{ 'builder.header' | translate }}</h2>
<div class="editor-grid three toggles">
@for (item of items; track item.key) {
<label class="toggle-row">
<input type="checkbox" [checked]="isChecked(item.key)" (change)="toggle(item.key, $any($event.target).checked)" />
<span>{{ item.label | translate }}</span>
</label>
}
</div>
</section>
}

View File

@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { HeaderConfig } from '../../../shared/models/config';
@Component({
selector: 'app-project-editor-header-section',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './header-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorHeaderSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
readonly items: Array<{ key: keyof HeaderConfig; label: string }> = [
{ key: 'showLogo', label: 'builder.showLogo' },
{ key: 'showSearch', label: 'builder.showSearch' },
{ key: 'showCategories', label: 'builder.showCategories' },
{ key: 'showLanguages', label: 'builder.showLanguages' },
{ key: 'showCart', label: 'builder.showCart' },
{ key: 'showProfile', label: 'builder.showProfile' },
{ key: 'showWishlist', label: 'builder.showWishlist' },
{ key: 'showCompare', label: 'builder.showCompare' },
{ key: 'showRegion', label: 'builder.showRegion' },
];
toggle(key: keyof HeaderConfig, checked: boolean): void {
this.facade.updateBootstrap(current => ({
...current,
header: { ...(current.header ?? {}), [key]: checked }
}));
}
isChecked(key: keyof HeaderConfig): boolean {
return !!this.bootstrap()?.header?.[key];
}
}

View File

@@ -0,0 +1,17 @@
@if (homePage()) {
<section class="editor-section-card">
<h2>{{ 'builder.homepage' | translate }}</h2>
<div cdkDropList class="sortable-list" (cdkDropListDropped)="drop($event)">
@for (section of sections(); track section.id) {
<div class="sortable-item" cdkDrag>
<div class="editor-grid four compact">
<strong>{{ section.id }}</strong>
<label><span>{{ 'builder.visible' | translate }}</span><input type="checkbox" [checked]="section.visible !== false" (change)="updateSection(section.id, 'visible', $any($event.target).checked)" /></label>
<label><span>{{ 'builder.layoutLabel' | translate }}</span><input type="text" [ngModel]="section.layout?.strategy || ''" (ngModelChange)="updateLayout(section.id, 'strategy', $event)" /></label>
<label><span>{{ 'builder.columns' | translate }}</span><input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" /></label>
</div>
</div>
}
</div>
</section>
}

View File

@@ -0,0 +1,60 @@
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-project-editor-homepage-section',
standalone: true,
imports: [DragDropModule, FormsModule, TranslatePipe],
templateUrl: './homepage-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorHomepageSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly homePage = this.facade.homepagePage;
readonly sections = computed(() => [...(this.homePage()?.sections ?? [])].sort((a, b) => a.order - b.order));
drop(event: CdkDragDrop<any[]>): void {
const sections = [...this.sections()];
moveItemInArray(sections, event.previousIndex, event.currentIndex);
this.facade.updateBootstrap(current => ({
...current,
pages: current.pages.map(page => page.id !== this.homePage()?.id ? page : ({
...page,
sections: sections.map((section, index) => ({ ...section, order: index + 1 }))
}))
}));
}
updateSection(sectionId: string, field: 'visible' | 'type', value: unknown): void {
this.facade.updateBootstrap(current => ({
...current,
pages: current.pages.map(page => page.id !== this.homePage()?.id ? page : ({
...page,
sections: page.sections.map(section => section.id !== sectionId ? section : ({
...section,
...(field === 'type' ? { type: String(value) } : { visible: Boolean(value) })
}))
}))
}));
}
updateLayout(sectionId: string, key: 'strategy' | 'columns', value: string): void {
this.facade.updateBootstrap(current => ({
...current,
pages: current.pages.map(page => page.id !== this.homePage()?.id ? page : ({
...page,
sections: page.sections.map(section => section.id !== sectionId ? section : ({
...section,
layout: {
...section.layout,
[key]: key === 'columns' ? Number(value) || 1 : value,
}
}))
}))
}));
}
}

View File

@@ -0,0 +1,19 @@
<section class="editor-section-card">
<h2>{{ 'builder.preview' | translate }}</h2>
<div class="editor-actions">
<button type="button" (click)="preview()">{{ 'builder.livePreview' | translate }}</button>
<button type="button" class="secondary" (click)="refreshExport()">{{ 'builder.exportBootstrap' | translate }}</button>
<button type="button" class="secondary" (click)="importDraft()">{{ 'builder.importBootstrap' | translate }}</button>
</div>
@if (importError()) {
<p class="editor-error">{{ importError()! | translate }}</p>
}
<label>
<span>{{ 'builder.exportedBootstrap' | translate }}</span>
<textarea rows="10" [ngModel]="exportValue()" (ngModelChange)="exportValue.set($event)"></textarea>
</label>
<label>
<span>{{ 'builder.importSource' | translate }}</span>
<textarea rows="10" [ngModel]="importValue()" (ngModelChange)="importValue.set($event)"></textarea>
</label>
</section>

View File

@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-project-editor-preview-section',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './preview-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorPreviewSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly exportValue = signal('');
readonly importValue = signal('');
readonly importError = this.facade.importError;
refreshExport(): void {
this.exportValue.set(this.facade.exportBootstrap());
}
importDraft(): void {
this.facade.importBootstrap(this.importValue());
this.refreshExport();
}
preview(): void {
this.facade.preview();
}
}

View File

@@ -0,0 +1,125 @@
:host {
display: block;
}
.editor-section-card {
background: #fff;
border: 1px solid var(--border-color, #d3dad9);
border-radius: 16px;
padding: 18px;
display: grid;
gap: 14px;
}
.editor-grid {
display: grid;
gap: 12px;
}
.editor-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.editor-grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.editor-grid.four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.editor-grid.compact {
align-items: end;
}
label {
display: grid;
gap: 6px;
color: var(--text-primary, #1e3c38);
font-weight: 600;
}
label.full {
grid-column: 1 / -1;
}
input,
textarea,
select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color, #d3dad9);
border-radius: 10px;
font: inherit;
background: #fff;
}
input[type='checkbox'] {
width: auto;
padding: 0;
}
input[type='color'] {
min-height: 44px;
padding: 4px;
}
.toggle-row {
display: flex;
align-items: center;
gap: 8px;
}
.toggles {
align-items: start;
}
.sortable-list,
.stack-list {
display: grid;
gap: 10px;
}
.sortable-item,
.sub-card {
border: 1px solid var(--border-color, #d3dad9);
border-radius: 12px;
padding: 12px;
background: #fbfcfc;
}
.editor-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button {
min-height: 42px;
border-radius: 10px;
border: 1px solid #497671;
background: #497671;
color: #fff;
padding: 0 14px;
font-weight: 700;
cursor: pointer;
}
button.secondary {
background: #fff;
color: #1e3c38;
border-color: var(--border-color, #d3dad9);
}
.editor-error {
margin: 0;
color: #991b1b;
}
@media (max-width: 900px) {
.editor-grid.two,
.editor-grid.three,
.editor-grid.four {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,15 @@
@if (bootstrap(); as bootstrap) {
<section class="editor-section-card">
<h2>{{ 'builder.theme' | translate }}</h2>
<div class="editor-grid two">
<label><span>{{ 'builder.primaryColor' | translate }}</span><input type="color" [ngModel]="bootstrap.theme.palette.primary" (ngModelChange)="updatePalette('primary', $event)" /></label>
<label><span>{{ 'builder.secondaryColor' | translate }}</span><input type="color" [ngModel]="bootstrap.theme.palette.secondary" (ngModelChange)="updatePalette('secondary', $event)" /></label>
<label><span>{{ 'builder.backgroundColor' | translate }}</span><input type="color" [ngModel]="bootstrap.theme.palette.backgroundPrimary" (ngModelChange)="updatePalette('backgroundPrimary', $event)" /></label>
<label><span>{{ 'builder.surfaceColor' | translate }}</span><input type="color" [ngModel]="bootstrap.theme.palette.backgroundSecondary" (ngModelChange)="updatePalette('backgroundSecondary', $event)" /></label>
<label><span>{{ 'builder.textColor' | translate }}</span><input type="color" [ngModel]="bootstrap.theme.palette.textPrimary" (ngModelChange)="updatePalette('textPrimary', $event)" /></label>
<label><span>{{ 'builder.successColor' | translate }}</span><input type="color" [ngModel]="bootstrap.theme.palette.success" (ngModelChange)="updatePalette('success', $event)" /></label>
<label><span>{{ 'builder.warningColor' | translate }}</span><input type="color" [ngModel]="bootstrap.theme.palette.warning" (ngModelChange)="updatePalette('warning', $event)" /></label>
<label><span>{{ 'builder.dangerColor' | translate }}</span><input type="color" [ngModel]="bootstrap.theme.palette.danger" (ngModelChange)="updatePalette('danger', $event)" /></label>
</div>
</section>
}

View File

@@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { ThemePaletteConfig } from '../../../shared/models/config';
@Component({
selector: 'app-project-editor-theme-section',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './theme-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorThemeSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
updatePalette<K extends keyof ThemePaletteConfig>(key: K, value: string): void {
this.facade.updateBootstrap(current => ({
...current,
theme: { ...current.theme, palette: { ...current.theme.palette, [key]: value } }
}));
}
}

View File

@@ -0,0 +1,44 @@
@if (widgets().length > 0) {
<section class="editor-section-card">
<h2>{{ 'builder.widgets' | translate }}</h2>
<div class="stack-list">
@for (entry of widgets(); track entry.widget.id) {
<article class="sub-card">
<h3>{{ entry.widget.type }} · {{ entry.widget.id }}</h3>
@switch (entry.widget.type) {
@case ('hero') {
<div class="editor-grid two">
<label><span>{{ 'builder.layoutLabel' | translate }}</span><input type="text" [ngModel]="entry.widget.props['layout'] || ''" (ngModelChange)="updateProp(entry.widget.id, 'layout', $event)" /></label>
<label><span>{{ 'builder.height' | translate }}</span><input type="text" [ngModel]="entry.widget.props['height'] || ''" (ngModelChange)="updateProp(entry.widget.id, 'height', $event)" /></label>
<label><span>{{ 'builder.overlay' | translate }}</span><input type="checkbox" [checked]="!!entry.widget.props['overlay']" (change)="updateProp(entry.widget.id, 'overlay', $any($event.target).checked)" /></label>
<label><span>{{ 'builder.autoplay' | translate }}</span><input type="checkbox" [checked]="!!entry.widget.props['autoplay']" (change)="updateProp(entry.widget.id, 'autoplay', $any($event.target).checked)" /></label>
</div>
}
@case ('categories') {
<div class="editor-grid two">
<label><span>{{ 'builder.layoutLabel' | translate }}</span><input type="text" [ngModel]="entry.widget.props['layout'] || ''" (ngModelChange)="updateProp(entry.widget.id, 'layout', $event)" /></label>
<label><span>{{ 'builder.columns' | translate }}</span><input type="number" [ngModel]="entry.widget.props['columns'] || 1" (ngModelChange)="updateProp(entry.widget.id, 'columns', +$event)" /></label>
</div>
}
@case ('product-collection') {
<div class="editor-grid three">
<label><span>{{ 'builder.layoutLabel' | translate }}</span><input type="text" [ngModel]="entry.widget.props['layout'] || ''" (ngModelChange)="updateProp(entry.widget.id, 'layout', $event)" /></label>
<label><span>{{ 'builder.cardsPerRow' | translate }}</span><input type="number" [ngModel]="entry.widget.props['cardsPerRow'] || 4" (ngModelChange)="updateProp(entry.widget.id, 'cardsPerRow', +$event)" /></label>
<label><span>{{ 'builder.filtersLabel' | translate }}</span><input type="checkbox" [checked]="!!entry.widget.props['filters']" (change)="updateProp(entry.widget.id, 'filters', $any($event.target).checked)" /></label>
<label><span>{{ 'builder.showBadges' | translate }}</span><input type="checkbox" [checked]="!!entry.widget.props['showBadges']" (change)="updateProp(entry.widget.id, 'showBadges', $any($event.target).checked)" /></label>
<label><span>{{ 'builder.showRating' | translate }}</span><input type="checkbox" [checked]="!!entry.widget.props['showRating']" (change)="updateProp(entry.widget.id, 'showRating', $any($event.target).checked)" /></label>
<label><span>{{ 'builder.showPrice' | translate }}</span><input type="checkbox" [checked]="!!entry.widget.props['showPrice']" (change)="updateProp(entry.widget.id, 'showPrice', $any($event.target).checked)" /></label>
</div>
}
@default {
<label>
<span>{{ 'builder.widgetJson' | translate }}</span>
<textarea rows="8" [ngModel]="propsJson(entry.widget.props)" (ngModelChange)="updateJson(entry.widget.id, $event)"></textarea>
</label>
}
}
</article>
}
</div>
</section>
}

View File

@@ -0,0 +1,49 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-project-editor-widgets-section',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './widgets-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorWidgetsSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly widgets = this.facade.homepageWidgets;
updateWidget(widgetId: string, updater: (props: Record<string, unknown>) => Record<string, unknown>): void {
this.facade.updateBootstrap(current => ({
...current,
pages: current.pages.map(page => ({
...page,
sections: page.sections.map(section => ({
...section,
widgets: section.widgets.map(widget => widget.id !== widgetId ? widget : ({
...widget,
props: updater(widget.props ?? {})
}))
}))
}))
}));
}
updateProp(widgetId: string, key: string, value: unknown): void {
this.updateWidget(widgetId, props => ({ ...props, [key]: value }));
}
updateJson(widgetId: string, raw: string): void {
try {
this.updateWidget(widgetId, () => JSON.parse(raw));
} catch {
// Ignore malformed draft until valid JSON is provided.
}
}
propsJson(props: Record<string, unknown>): string {
return JSON.stringify(props ?? {}, null, 2);
}
}

View File

@@ -0,0 +1,13 @@
import { Injectable } from '@angular/core';
import { BootstrapConfig } from '../../../shared/models/config';
@Injectable({ providedIn: 'root' })
export class ProjectEditorIoService {
exportBootstrap(config: BootstrapConfig): string {
return JSON.stringify(config, null, 2);
}
importBootstrap(raw: string): BootstrapConfig {
return JSON.parse(raw) as BootstrapConfig;
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { BootstrapConfig } from '../../../shared/models/config';
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
import { LanguageService } from '../../../services/language.service';
import { UiRuntimeFacade } from '../../../facades/runtime/ui-runtime.facade';
@Injectable({ providedIn: 'root' })
export class ProjectEditorPreviewService {
private readonly runtime = inject(PlatformRuntimeService);
private readonly router = inject(Router);
private readonly languageService = inject(LanguageService);
private readonly uiRuntime = inject(UiRuntimeFacade);
preview(bootstrap: BootstrapConfig): void {
this.runtime.reloadFromBootstrap(bootstrap);
this.uiRuntime.reloadFromBootstrap(bootstrap);
void this.router.navigate([`/${this.languageService.currentLanguage()}`]);
}
}

View File

@@ -398,10 +398,77 @@ export const en: Translations = {
notAvailable: 'n/a',
},
builder: {
title: 'Builder Sandbox',
subtitle: 'Configuration-only editing surface.',
title: 'Marketplace Project Editor',
subtitle: 'Configuration-only bootstrap editing surface.',
general: 'General',
branding: 'Branding',
theme: 'Theme',
header: 'Header',
footer: 'Footer',
homepage: 'Homepage',
widgets: 'Widgets',
marketplaceFeatures: 'Marketplace Features',
preview: 'Preview',
brandName: 'Brand Name',
marketplaceName: 'Marketplace Name',
domain: 'Domain',
descriptionLabel: 'Description',
defaultLanguage: 'Default Language',
supportedLanguages: 'Supported Languages',
logo: 'Logo',
smallLogo: 'Small Logo',
favicon: 'Favicon',
marketplaceTitle: 'Marketplace Title',
primaryColor: 'Primary Color',
secondaryColor: 'Secondary Color',
backgroundColor: 'Background',
surfaceColor: 'Surface',
textColor: 'Text',
successColor: 'Success',
warningColor: 'Warning',
dangerColor: 'Danger',
showLogo: 'Logo',
showSearch: 'Search',
showCategories: 'Categories',
showLanguages: 'Languages',
showCart: 'Cart',
showProfile: 'Profile',
showWishlist: 'Wishlist',
showCompare: 'Compare',
showRegion: 'Region',
companyName: 'Company Information',
address: 'Address',
phone: 'Phone',
email: 'Email',
copyright: 'Copyright',
paymentIcons: 'Payment Icons',
socialLinks: 'Social Links',
staticPages: 'Static Pages',
visible: 'Visible',
layoutLabel: 'Layout',
columns: 'Columns',
height: 'Height',
overlay: 'Overlay',
autoplay: 'Autoplay',
cardsPerRow: 'Cards Per Row',
filtersLabel: 'Filters',
showBadges: 'Show Badges',
showRating: 'Show Rating',
showPrice: 'Show Price',
widgetJson: 'Widget JSON',
reviews: 'Reviews',
questions: 'Questions',
comments: 'Comments',
recommendations: 'Recommendations',
recentlyViewed: 'Recently Viewed',
searchSuggestions: 'Search Suggestions',
searchHistory: 'Search History',
livePreview: 'Live Preview',
exportBootstrap: 'Export Bootstrap',
importBootstrap: 'Import Bootstrap',
exportedBootstrap: 'Exported Bootstrap',
importSource: 'Import Source',
importError: 'Import failed. Invalid bootstrap JSON.',
featureFlags: 'Feature Flags',
},
widgets: {

View File

@@ -398,10 +398,77 @@ export const hy: Translations = {
notAvailable: 'չկա',
},
builder: {
title: 'Builder Sandbox',
subtitle: 'Միայն կոնֆիգուրացիայի խմբագրման մակերես։',
title: 'Մարքեթփլեյսի նախագծի խմբագիր',
subtitle: 'Միայն bootstrap կարգավորման խմբագրման մակերես։',
general: 'Ընդհանուր',
branding: 'Բրենդինգ',
theme: 'Թեմա',
header: 'Header',
footer: 'Footer',
homepage: 'Գլխավոր էջ',
widgets: 'Վիջեթներ',
marketplaceFeatures: 'Մարքեթփլեյսի հնարավորություններ',
preview: 'Preview',
brandName: 'Բրենդի անվանում',
marketplaceName: 'Մարքեթփլեյսի անվանում',
domain: 'Դոմեյն',
descriptionLabel: 'Նկարագրություն',
defaultLanguage: 'Լռելյայն լեզու',
supportedLanguages: 'Աջակցվող լեզուներ',
logo: 'Լոգո',
smallLogo: 'Փոքր լոգո',
favicon: 'Favicon',
marketplaceTitle: 'Մարքեթփլեյսի վերնագիր',
primaryColor: 'Հիմնական գույն',
secondaryColor: 'Երկրորդական գույն',
backgroundColor: 'Ֆոն',
surfaceColor: 'Մակերես',
textColor: 'Տեքստ',
successColor: 'Success',
warningColor: 'Warning',
dangerColor: 'Danger',
showLogo: 'Լոգո',
showSearch: 'Որոնում',
showCategories: 'Կատեգորիաներ',
showLanguages: 'Լեզուներ',
showCart: 'Զամբյուղ',
showProfile: 'Պրոֆիլ',
showWishlist: 'Ընտրյալներ',
showCompare: 'Համեմատում',
showRegion: 'Տարածաշրջան',
companyName: 'Ընկերության տվյալներ',
address: 'Հասցե',
phone: 'Հեռախոս',
email: 'Email',
copyright: 'Copyright',
paymentIcons: 'Վճարման icon-ներ',
socialLinks: 'Սոցիալական հղումներ',
staticPages: 'Ստատիկ էջեր',
visible: 'Տեսանելի',
layoutLabel: 'Layout',
columns: 'Սյուներ',
height: 'Բարձրություն',
overlay: 'Overlay',
autoplay: 'Autoplay',
cardsPerRow: 'Քարտեր մեկ շարքում',
filtersLabel: 'Ֆիլտրեր',
showBadges: 'Ցույց տալ badges',
showRating: 'Ցույց տալ վարկանիշ',
showPrice: 'Ցույց տալ գին',
widgetJson: 'Widget JSON',
reviews: 'Կարծիքներ',
questions: 'Հարցեր',
comments: 'Մեկնաբանություններ',
recommendations: 'Առաջարկություններ',
recentlyViewed: 'Վերջին դիտվածներ',
searchSuggestions: 'Որոնման առաջարկներ',
searchHistory: 'Որոնման պատմություն',
livePreview: 'Կենդանի preview',
exportBootstrap: 'Արտահանել bootstrap',
importBootstrap: 'Ներմուծել bootstrap',
exportedBootstrap: 'Արտահանված bootstrap',
importSource: 'Ներմուծման աղբյուր',
importError: 'Ներմուծումը ձախողվեց։ Սխալ bootstrap JSON։',
featureFlags: 'Feature flags',
},
widgets: {

View File

@@ -398,10 +398,77 @@ export const ru: Translations = {
notAvailable: 'н/д',
},
builder: {
title: 'Песочница Builder',
subtitle: 'Поверхность редактирования только конфигурации.',
title: 'Редактор проекта маркетплейса',
subtitle: 'Поверхность редактирования только bootstrap-конфигурации.',
general: 'Общие',
branding: 'Брендинг',
theme: 'Тема',
header: 'Хедер',
footer: 'Футер',
homepage: 'Главная страница',
widgets: 'Виджеты',
marketplaceFeatures: 'Функции маркетплейса',
preview: 'Превью',
brandName: 'Название бренда',
marketplaceName: 'Название маркетплейса',
domain: 'Домен',
descriptionLabel: 'Описание',
defaultLanguage: 'Язык по умолчанию',
supportedLanguages: 'Поддерживаемые языки',
logo: 'Логотип',
smallLogo: 'Малый логотип',
favicon: 'Favicon',
marketplaceTitle: 'Заголовок маркетплейса',
primaryColor: 'Основной цвет',
secondaryColor: 'Вторичный цвет',
backgroundColor: 'Фон',
surfaceColor: 'Поверхность',
textColor: 'Текст',
successColor: 'Success',
warningColor: 'Warning',
dangerColor: 'Danger',
showLogo: 'Логотип',
showSearch: 'Поиск',
showCategories: 'Категории',
showLanguages: 'Языки',
showCart: 'Корзина',
showProfile: 'Профиль',
showWishlist: 'Избранное',
showCompare: 'Сравнение',
showRegion: 'Регион',
companyName: 'Информация о компании',
address: 'Адрес',
phone: 'Телефон',
email: 'Email',
copyright: 'Копирайт',
paymentIcons: 'Иконки оплаты',
socialLinks: 'Социальные ссылки',
staticPages: 'Статические страницы',
visible: 'Видимость',
layoutLabel: 'Layout',
columns: 'Колонки',
height: 'Высота',
overlay: 'Overlay',
autoplay: 'Autoplay',
cardsPerRow: 'Карточек в ряд',
filtersLabel: 'Фильтры',
showBadges: 'Показывать badges',
showRating: 'Показывать рейтинг',
showPrice: 'Показывать цену',
widgetJson: 'JSON виджета',
reviews: 'Отзывы',
questions: 'Вопросы',
comments: 'Комментарии',
recommendations: 'Рекомендации',
recentlyViewed: 'Недавно просмотренные',
searchSuggestions: 'Поисковые подсказки',
searchHistory: 'История поиска',
livePreview: 'Живое превью',
exportBootstrap: 'Экспорт bootstrap',
importBootstrap: 'Импорт bootstrap',
exportedBootstrap: 'Экспортированный bootstrap',
importSource: 'Источник импорта',
importError: 'Импорт не удался. Некорректный JSON bootstrap.',
featureFlags: 'Фичи-флаги',
},
widgets: {

View File

@@ -398,8 +398,75 @@ export interface Translations {
builder: {
title: string;
subtitle: string;
general: string;
branding: string;
theme: string;
header: string;
footer: string;
homepage: string;
widgets: string;
marketplaceFeatures: string;
preview: string;
brandName: string;
marketplaceName: string;
domain: string;
descriptionLabel: string;
defaultLanguage: string;
supportedLanguages: string;
logo: string;
smallLogo: string;
favicon: string;
marketplaceTitle: string;
primaryColor: string;
secondaryColor: string;
backgroundColor: string;
surfaceColor: string;
textColor: string;
successColor: string;
warningColor: string;
dangerColor: string;
showLogo: string;
showSearch: string;
showCategories: string;
showLanguages: string;
showCart: string;
showProfile: string;
showWishlist: string;
showCompare: string;
showRegion: string;
companyName: string;
address: string;
phone: string;
email: string;
copyright: string;
paymentIcons: string;
socialLinks: string;
staticPages: string;
visible: string;
layoutLabel: string;
columns: string;
height: string;
overlay: string;
autoplay: string;
cardsPerRow: string;
filtersLabel: string;
showBadges: string;
showRating: string;
showPrice: string;
widgetJson: string;
reviews: string;
questions: string;
comments: string;
recommendations: string;
recentlyViewed: string;
searchSuggestions: string;
searchHistory: string;
livePreview: string;
exportBootstrap: string;
importBootstrap: string;
exportedBootstrap: string;
importSource: string;
importError: string;
featureFlags: string;
};
widgets: {

View File

@@ -4,6 +4,7 @@ import { CatalogConfig } from './catalog-config.model';
import { CompanyConfig } from './company.model';
import { FeatureFlagsConfig } from './feature-flags.model';
import { FooterConfig } from './footer-config.model';
import { HeaderConfig } from './header-config.model';
import { PlatformLayoutConfig } from './layout.model';
import { LocalizationConfig } from './localization.model';
import { NavigationConfig } from './navigation.model';
@@ -29,6 +30,7 @@ export interface BootstrapConfig {
localization: LocalizationConfig;
seo: SeoConfig;
permissions: PermissionsConfig;
header?: HeaderConfig;
catalog?: CatalogConfig;
layout?: PlatformLayoutConfig;
navigation: NavigationConfig;

View File

@@ -2,6 +2,9 @@ export interface FeatureFlagsConfig {
wishlist: boolean;
compare: boolean;
reviews: boolean;
questions: boolean;
comments: boolean;
recommendations: boolean;
blog: boolean;
chat: boolean;
analytics: boolean;

View File

@@ -7,9 +7,18 @@ export interface FooterPaymentIconConfig {
height?: number;
}
export interface FooterSocialLinkConfig {
id: string;
label: string;
url: string;
icon?: string;
}
export interface FooterConfig {
logoUrl?: string;
paymentIcons?: FooterPaymentIconConfig[];
copyrightText?: string | LocalizedTextContent;
legalPageKeys?: string[];
staticPageKeys?: string[];
socialLinks?: FooterSocialLinkConfig[];
}

View File

@@ -0,0 +1,23 @@
export interface HeaderConfig {
showLogo?: boolean;
showSearch?: boolean;
showCategories?: boolean;
showLanguages?: boolean;
showCart?: boolean;
showProfile?: boolean;
showWishlist?: boolean;
showCompare?: boolean;
showRegion?: boolean;
}
export const DEFAULT_HEADER_CONFIG: Required<HeaderConfig> = {
showLogo: true,
showSearch: true,
showCategories: true,
showLanguages: true,
showCart: true,
showProfile: false,
showWishlist: true,
showCompare: true,
showRegion: true,
};

View File

@@ -5,6 +5,7 @@ export * from './catalog-config.model';
export * from './company.model';
export * from './feature-flags.model';
export * from './footer-config.model';
export * from './header-config.model';
export * from './layout.model';
export * from './localization.model';
export * from './navigation.model';