feat(builder): add project editor
This commit is contained in:
138
docs/Project-Editor.md
Normal file
138
docs/Project-Editor.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Marketplace Project Editor - Sprint 13
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Sprint 13 introduces first version of Marketplace Project Editor.
|
||||||
|
|
||||||
|
Editor only manages bootstrap configuration.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
- products
|
||||||
|
- categories
|
||||||
|
- orders
|
||||||
|
- analytics management
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/app/features/project-editor/
|
||||||
|
pages/
|
||||||
|
components/
|
||||||
|
sections/
|
||||||
|
models/
|
||||||
|
services/
|
||||||
|
facade/
|
||||||
|
```
|
||||||
|
|
||||||
|
Editor uses strongly typed bootstrap models and isolated feature state.
|
||||||
|
Components do not call APIs directly.
|
||||||
|
|
||||||
|
## Facade API
|
||||||
|
|
||||||
|
`ProjectEditorFacade` exposes:
|
||||||
|
- `loadBootstrap()`
|
||||||
|
- `updateBootstrap()`
|
||||||
|
- `exportBootstrap()`
|
||||||
|
- `importBootstrap()`
|
||||||
|
- `preview()`
|
||||||
|
|
||||||
|
Current source of truth:
|
||||||
|
- existing bootstrap provider via `ConfigService`
|
||||||
|
|
||||||
|
Future backend integration:
|
||||||
|
- replace local import/export/save flow with builder endpoints
|
||||||
|
- keep component contracts unchanged
|
||||||
|
|
||||||
|
## Supported Sections
|
||||||
|
|
||||||
|
- General
|
||||||
|
- marketplace name
|
||||||
|
- domain
|
||||||
|
- description
|
||||||
|
- default language
|
||||||
|
- supported languages
|
||||||
|
- Branding
|
||||||
|
- logo
|
||||||
|
- small logo
|
||||||
|
- favicon
|
||||||
|
- marketplace title
|
||||||
|
- Theme
|
||||||
|
- palette tokens via color pickers
|
||||||
|
- Header
|
||||||
|
- logo/search/categories/languages/cart/profile/wishlist/compare/region toggles
|
||||||
|
- Footer
|
||||||
|
- company information
|
||||||
|
- address
|
||||||
|
- phone
|
||||||
|
- email
|
||||||
|
- copyright
|
||||||
|
- payment icons
|
||||||
|
- social links
|
||||||
|
- static pages
|
||||||
|
- Homepage
|
||||||
|
- home page sections list
|
||||||
|
- visibility
|
||||||
|
- order
|
||||||
|
- layout
|
||||||
|
- drag-and-drop ordering
|
||||||
|
- Widgets
|
||||||
|
- homepage widget configuration
|
||||||
|
- typed shortcuts for hero/categories/product collection widgets
|
||||||
|
- JSON fallback for other widget props
|
||||||
|
- Marketplace Features
|
||||||
|
- feature flags
|
||||||
|
- search suggestions/history
|
||||||
|
- recently viewed
|
||||||
|
- reviews/questions/recommendations
|
||||||
|
- Preview
|
||||||
|
- export JSON
|
||||||
|
- import JSON
|
||||||
|
- runtime preview without full browser refresh
|
||||||
|
|
||||||
|
## Preview Strategy
|
||||||
|
|
||||||
|
Preview updates bootstrap snapshot in memory and re-applies:
|
||||||
|
- theme
|
||||||
|
- branding
|
||||||
|
- runtime state
|
||||||
|
- route-based page rendering on next Angular navigation
|
||||||
|
|
||||||
|
This keeps bootstrap engine intact while enabling fast local preview.
|
||||||
|
|
||||||
|
## Widget Configuration
|
||||||
|
|
||||||
|
Current widget editor supports explicit fields for:
|
||||||
|
- Hero
|
||||||
|
- layout
|
||||||
|
- height
|
||||||
|
- overlay
|
||||||
|
- autoplay
|
||||||
|
- Categories
|
||||||
|
- layout
|
||||||
|
- columns
|
||||||
|
- Product Collection
|
||||||
|
- layout
|
||||||
|
- cards per row
|
||||||
|
- filters
|
||||||
|
- badges
|
||||||
|
- rating
|
||||||
|
- price
|
||||||
|
|
||||||
|
Other widgets use JSON props fallback until dedicated editors are added.
|
||||||
|
|
||||||
|
## Future Backend Endpoints
|
||||||
|
|
||||||
|
Recommended future builder endpoints:
|
||||||
|
- `GET /builder/bootstrap`
|
||||||
|
- `PUT /builder/bootstrap`
|
||||||
|
- `POST /builder/bootstrap/preview`
|
||||||
|
- `POST /builder/bootstrap/validate`
|
||||||
|
- `POST /builder/bootstrap/import`
|
||||||
|
- `GET /builder/bootstrap/export`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- runtime bootstrap engine not replaced
|
||||||
|
- configuration stays source of truth
|
||||||
|
- no hardcoded marketplace values
|
||||||
|
- no business domain management mixed into editor
|
||||||
@@ -86,3 +86,8 @@
|
|||||||
- Возвращать валидный bootstrap JSON для каждого tenant.
|
- Возвращать валидный bootstrap JSON для каждого tenant.
|
||||||
- Поддерживать согласованные API-контракты каталога.
|
- Поддерживать согласованные API-контракты каталога.
|
||||||
- Обеспечивать обратную совместимость либо явно повышать schemaVersion.
|
- Обеспечивать обратную совместимость либо явно повышать schemaVersion.
|
||||||
|
|
||||||
|
## Project Editor
|
||||||
|
- Внутренний Project Editor редактирует только bootstrap-конфигурацию маркетплейса.
|
||||||
|
- Editor не управляет товарами, категориями, заказами или аналитикой.
|
||||||
|
- Первичная версия поддерживает секции general, branding, theme, header, footer, homepage, widgets, marketplace features и preview.
|
||||||
|
|||||||
@@ -171,3 +171,9 @@ Bootstrap JSON является главным конфигурационным
|
|||||||
- Отдавать tenant-specific bootstrap.json.
|
- Отдавать tenant-specific bootstrap.json.
|
||||||
- Поддерживать schemaVersion и changelog контракта.
|
- Поддерживать schemaVersion и changelog контракта.
|
||||||
- Не включать frontend-специфические runtime-хуки в JSON.
|
- Не включать frontend-специфические runtime-хуки в JSON.
|
||||||
|
|
||||||
|
## Project Editor Coverage
|
||||||
|
- Marketplace Project Editor изменяет bootstrap-конфигурацию без редактирования доменных сущностей.
|
||||||
|
- Editor работает со строго типизированными разделами: general, branding, theme, header, footer, homepage, widgets, marketplace features.
|
||||||
|
- Под preview допускается in-memory override bootstrap snapshot без полной перезагрузки браузера.
|
||||||
|
- Будущие builder endpoints должны повторять контракты BootstrapConfig без tenant-specific веток во frontend.
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
## Project Editor Widget Support
|
||||||
|
- Project Editor предоставляет typed editing для homepage widgets.
|
||||||
|
- Hero: layout, height, overlay, autoplay.
|
||||||
|
- Categories: layout, columns.
|
||||||
|
- Product collection: layout, cards per row, filters, badges, rating, price.
|
||||||
|
- Остальные виджеты редактируются через JSON props fallback до появления специализированных editors.
|
||||||
# 05. Widget System
|
# 05. Widget System
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ const coreRoutes: Routes = [
|
|||||||
path: 'search',
|
path: 'search',
|
||||||
loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent)
|
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',
|
path: 'wishlist',
|
||||||
loadComponent: () => import('./features/website/user-experience/wishlist/containers/wishlist-page.component').then(m => m.WishlistPageComponent)
|
loadComponent: () => import('./features/website/user-experience/wishlist/containers/wishlist-page.component').then(m => m.WishlistPageComponent)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, effect, inject, signal } from '@angular/core';
|
||||||
import { take } from 'rxjs/operators';
|
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
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 { FooterPaymentIcon, FooterResolvedGroup, FooterResolverService } from '../../core/config/footer-resolver.service';
|
import { FooterPaymentIcon, FooterResolvedGroup, FooterResolverService } from '../../core/config/footer-resolver.service';
|
||||||
|
import { ConfigService } from '../../core/config/config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-footer',
|
selector: 'app-footer',
|
||||||
@@ -19,22 +19,29 @@ export class FooterComponent {
|
|||||||
readonly paymentIcons = signal<FooterPaymentIcon[]>([]);
|
readonly paymentIcons = signal<FooterPaymentIcon[]>([]);
|
||||||
readonly copyrightText = signal('');
|
readonly copyrightText = signal('');
|
||||||
|
|
||||||
|
private readonly configService = inject(ConfigService);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly uiRuntime: UiRuntimeFacade,
|
private readonly uiRuntime: UiRuntimeFacade,
|
||||||
private readonly footerResolver: FooterResolverService
|
private readonly footerResolver: FooterResolverService
|
||||||
) {
|
) {
|
||||||
this.footerResolver.resolveFooterModel().pipe(take(1)).subscribe({
|
effect(() => {
|
||||||
next: model => {
|
this.configService.bootstrapRevision();
|
||||||
this.footerGroups.set(model.groups);
|
const bootstrap = this.configService.getBootstrapSnapshot();
|
||||||
this.paymentIcons.set(model.paymentIcons);
|
if (!bootstrap) {
|
||||||
this.copyrightText.set(model.copyrightText);
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.footerGroups.set([]);
|
this.footerGroups.set([]);
|
||||||
this.paymentIcons.set([]);
|
this.paymentIcons.set([]);
|
||||||
this.copyrightText.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();
|
currentYear = new Date().getFullYear();
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
<header class="platform-header">
|
<header class="platform-header">
|
||||||
<div class="platform-header-container">
|
<div class="platform-header-container">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
|
@if (headerConfig().showLogo) {
|
||||||
<a [attr.href]="homeUrl" class="platform-logo" (click)="navigateHome($event)">
|
<a [attr.href]="homeUrl" class="platform-logo" (click)="navigateHome($event)">
|
||||||
<app-logo />
|
<app-logo />
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Navigation Buttons (desktop) -->
|
<!-- Navigation Buttons (desktop) -->
|
||||||
<nav class="platform-nav">
|
<nav class="platform-nav">
|
||||||
@@ -13,11 +15,17 @@
|
|||||||
(click)="closeMenu()" class="platform-nav-btn platform-nav-btn-left">
|
(click)="closeMenu()" class="platform-nav-btn platform-nav-btn-left">
|
||||||
{{ 'header.home' | translate }}
|
{{ 'header.home' | translate }}
|
||||||
</a>
|
</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. -->
|
<!-- TODO(CMS): Render backend-configured header content links here. -->
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Search Box (desktop) -->
|
<!-- Search Box (desktop) -->
|
||||||
|
@if (headerConfig().showSearch) {
|
||||||
<div class="platform-search-wrapper">
|
<div class="platform-search-wrapper">
|
||||||
<div class="platform-search-box">
|
<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">
|
<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 />
|
<input type="text" [placeholder]="'header.searchPlaceholder' | translate" class="platform-search-input" (click)="navigateToSearch()" readonly />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Search Icon (mobile only) -->
|
<!-- Search Icon (mobile only) -->
|
||||||
|
@if (headerConfig().showSearch) {
|
||||||
<button class="platform-search-mobile" (click)="navigateToSearch()" [attr.aria-label]="'header.search' | translate">
|
<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">
|
<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="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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Right Actions -->
|
<!-- Right Actions -->
|
||||||
<div class="platform-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">
|
<button type="button" class="platform-ux-btn" (click)="navigateToWishlist()" [attr.aria-label]="'header.wishlist' | translate">
|
||||||
<span class="platform-ux-icon">♥</span>
|
<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>
|
<span class="platform-ux-badge">{{ wishlistCount() }}</span>
|
||||||
}
|
}
|
||||||
</button>
|
</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">
|
<button type="button" class="platform-ux-btn" (click)="navigateToCompare()" [attr.aria-label]="'header.compare' | translate">
|
||||||
<span class="platform-ux-icon">⇄</span>
|
<span class="platform-ux-icon">⇄</span>
|
||||||
@if (compareCount() > 0) {
|
@if (compareCount() > 0) {
|
||||||
@@ -57,6 +68,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- Cart Button -->
|
<!-- Cart Button -->
|
||||||
|
@if (headerConfig().showCart) {
|
||||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="platform-cart-active" class="platform-cart-btn" (click)="closeMenu()">
|
<a [routerLink]="'/cart' | langRoute" routerLinkActive="platform-cart-active" class="platform-cart-btn" (click)="closeMenu()">
|
||||||
<span class="platform-cart-icon">
|
<span class="platform-cart-icon">
|
||||||
<svg width="32" height="24" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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>
|
<span class="platform-cart-total">{{ formatCartTotal(cartTotal()) }}</span>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Region Selector (desktop only) -->
|
<!-- Region Selector (desktop only) -->
|
||||||
|
@if (headerConfig().showRegion) {
|
||||||
<div class="platform-region-selector platform-lang-desktop">
|
<div class="platform-region-selector platform-lang-desktop">
|
||||||
<app-region-selector />
|
<app-region-selector />
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Language Selector (desktop only) -->
|
<!-- Language Selector (desktop only) -->
|
||||||
|
@if (headerConfig().showLanguages) {
|
||||||
<div class="platform-lang-selector platform-lang-desktop">
|
<div class="platform-lang-selector platform-lang-desktop">
|
||||||
<app-language-selector />
|
<app-language-selector />
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Mobile Menu Toggle -->
|
<!-- 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">
|
<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>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@if (headerConfig().showCategories) {
|
||||||
<a (click)="navigateToCatalog()" class="platform-mobile-item" style="cursor: pointer;">
|
<a (click)="navigateToCatalog()" class="platform-mobile-item" style="cursor: pointer;">
|
||||||
<svg width="24" height="24" viewBox="0 0 31 31" fill="none">
|
<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" />
|
<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"/>
|
<path d="M1 1L7 7L1 13" stroke="#697777" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- TODO(CMS): Render backend-configured mobile content links here. -->
|
<!-- TODO(CMS): Render backend-configured mobile content links here. -->
|
||||||
|
|
||||||
<div class="platform-mobile-controls">
|
<div class="platform-mobile-controls">
|
||||||
|
@if (headerConfig().showRegion) {
|
||||||
<div class="platform-mobile-lang">
|
<div class="platform-mobile-lang">
|
||||||
<app-region-selector />
|
<app-region-selector />
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
@if (headerConfig().showLanguages) {
|
||||||
<div class="platform-mobile-lang">
|
<div class="platform-mobile-lang">
|
||||||
<app-language-selector />
|
<app-language-selector />
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Router, RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { CartService } from '../../services/cart.service';
|
import { CartService } from '../../services/cart.service';
|
||||||
import { LanguageService } from '../../services/language.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 { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
||||||
import { UserExperienceFacade } from '../../facades/platform/user-experience.facade';
|
import { UserExperienceFacade } from '../../facades/platform/user-experience.facade';
|
||||||
import { ConfigService } from '../../core/config/config.service';
|
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({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
@@ -33,7 +33,8 @@ export class HeaderComponent {
|
|||||||
|
|
||||||
readonly wishlistCount = this.uxFacade.wishlistCount;
|
readonly wishlistCount = this.uxFacade.wishlistCount;
|
||||||
readonly compareCount = this.uxFacade.compareCount;
|
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) {
|
constructor(private cartService: CartService, private router: Router) {
|
||||||
this.cartItemCount = this.cartService.itemCount;
|
this.cartItemCount = this.cartService.itemCount;
|
||||||
@@ -127,6 +128,7 @@ export class HeaderComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveUserExperienceConfig() {
|
private resolveUserExperienceConfig() {
|
||||||
|
this.configService.bootstrapRevision();
|
||||||
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience ?? {};
|
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience ?? {};
|
||||||
|
|
||||||
return {
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { Observable, throwError } from 'rxjs';
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
import { catchError, shareReplay, tap } from 'rxjs/operators';
|
import { catchError, shareReplay, tap } from 'rxjs/operators';
|
||||||
import { BootstrapConfig } from '../../shared/models/config';
|
import { BootstrapConfig } from '../../shared/models/config';
|
||||||
import { CONFIG_PROVIDER } from './config-provider.token';
|
import { CONFIG_PROVIDER } from './config-provider.token';
|
||||||
@@ -10,12 +10,20 @@ export class ConfigService {
|
|||||||
|
|
||||||
private bootstrapSnapshot: BootstrapConfig | null = null;
|
private bootstrapSnapshot: BootstrapConfig | null = null;
|
||||||
private bootstrap$?: Observable<BootstrapConfig>;
|
private bootstrap$?: Observable<BootstrapConfig>;
|
||||||
|
private readonly revisionState = signal(0);
|
||||||
|
|
||||||
|
readonly bootstrapRevision = this.revisionState.asReadonly();
|
||||||
|
|
||||||
loadBootstrap(forceRefresh: boolean = false): Observable<BootstrapConfig> {
|
loadBootstrap(forceRefresh: boolean = false): Observable<BootstrapConfig> {
|
||||||
|
if (this.bootstrapSnapshot && !forceRefresh && this.bootstrap$) {
|
||||||
|
return this.bootstrap$;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.bootstrap$ || forceRefresh) {
|
if (!this.bootstrap$ || forceRefresh) {
|
||||||
this.bootstrap$ = this.provider.loadBootstrap().pipe(
|
this.bootstrap$ = this.provider.loadBootstrap().pipe(
|
||||||
tap(config => {
|
tap(config => {
|
||||||
this.bootstrapSnapshot = config;
|
this.bootstrapSnapshot = config;
|
||||||
|
this.revisionState.update(value => value + 1);
|
||||||
}),
|
}),
|
||||||
shareReplay(1),
|
shareReplay(1),
|
||||||
catchError(error => {
|
catchError(error => {
|
||||||
@@ -32,4 +40,11 @@ export class ConfigService {
|
|||||||
getBootstrapSnapshot(): BootstrapConfig | null {
|
getBootstrapSnapshot(): BootstrapConfig | null {
|
||||||
return this.bootstrapSnapshot;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,16 @@ export class FooterResolverService {
|
|||||||
|
|
||||||
resolveFooterModel(): Observable<FooterResolvedModel> {
|
resolveFooterModel(): Observable<FooterResolvedModel> {
|
||||||
return this.configService.loadBootstrap().pipe(
|
return this.configService.loadBootstrap().pipe(
|
||||||
map((bootstrap) => ({
|
map((bootstrap) => this.resolveFooterModelFromBootstrap(bootstrap))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveFooterModelFromBootstrap(bootstrap: BootstrapConfig): FooterResolvedModel {
|
||||||
|
return {
|
||||||
groups: this.resolveFooterGroups(bootstrap),
|
groups: this.resolveFooterGroups(bootstrap),
|
||||||
paymentIcons: this.resolvePaymentIcons(bootstrap.footer),
|
paymentIcons: this.resolvePaymentIcons(bootstrap.footer),
|
||||||
copyrightText: this.resolveCopyrightText(bootstrap)
|
copyrightText: this.resolveCopyrightText(bootstrap)
|
||||||
}))
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveFooterGroups(bootstrap: BootstrapConfig): FooterResolvedGroup[] {
|
private resolveFooterGroups(bootstrap: BootstrapConfig): FooterResolvedGroup[] {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BrandingEngineService } from '../../theme/runtime/branding-engine.servi
|
|||||||
import { ThemeEngineService } from '../../theme/runtime/theme-engine.service';
|
import { ThemeEngineService } from '../../theme/runtime/theme-engine.service';
|
||||||
import { WidgetRegistryBootstrapService } from '../../widgets/registry/widget-registry.bootstrap.service';
|
import { WidgetRegistryBootstrapService } from '../../widgets/registry/widget-registry.bootstrap.service';
|
||||||
import { PlatformRuntimeStateService } from './platform-runtime-state.service';
|
import { PlatformRuntimeStateService } from './platform-runtime-state.service';
|
||||||
|
import { BootstrapConfig } from '../../shared/models/config';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PlatformRuntimeService {
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, effect, signal } from '@angular/core';
|
||||||
import { take } from 'rxjs/operators';
|
|
||||||
import { ConfigService } from '../../core/config/config.service';
|
import { ConfigService } from '../../core/config/config.service';
|
||||||
|
import { BootstrapConfig } from '../../shared/models/config';
|
||||||
|
|
||||||
interface UiRuntimeState {
|
interface UiRuntimeState {
|
||||||
marketplaceName: string;
|
marketplaceName: string;
|
||||||
@@ -21,18 +21,26 @@ export class UiRuntimeFacade {
|
|||||||
});
|
});
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.configService.loadBootstrap().pipe(take(1)).subscribe({
|
effect(() => {
|
||||||
next: (bootstrap) => {
|
this.configService.bootstrapRevision();
|
||||||
|
const bootstrap = this.configService.getBootstrapSnapshot();
|
||||||
|
if (bootstrap) {
|
||||||
|
this.reloadFromBootstrap(bootstrap);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.configService.loadBootstrap().subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadFromBootstrap(bootstrap: BootstrapConfig): void {
|
||||||
this.state.set({
|
this.state.set({
|
||||||
marketplaceName: bootstrap.branding.brandName,
|
marketplaceName: bootstrap.branding.brandName,
|
||||||
marketplaceDisplayName: bootstrap.branding.brandName,
|
marketplaceDisplayName: bootstrap.branding.brandName,
|
||||||
logoUrl: bootstrap.branding.logoUrl,
|
logoUrl: bootstrap.branding.logoUrl,
|
||||||
contactEmail: bootstrap.branding.supportEmail ?? '',
|
contactEmail: bootstrap.branding.supportEmail ?? bootstrap.company?.contacts?.email ?? '',
|
||||||
themeId: bootstrap.theme.themeId
|
themeId: bootstrap.theme.themeId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
marketplaceName(): string {
|
marketplaceName(): string {
|
||||||
return this.state().marketplaceName;
|
return this.state().marketplaceName;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
];
|
||||||
|
}
|
||||||
106
src/app/features/project-editor/facade/project-editor.facade.ts
Normal file
106
src/app/features/project-editor/facade/project-editor.facade.ts
Normal 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;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 } }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/app/features/project-editor/sections/section.shared.scss
Normal file
125
src/app/features/project-editor/sections/section.shared.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 } }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()}`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -398,10 +398,77 @@ export const en: Translations = {
|
|||||||
notAvailable: 'n/a',
|
notAvailable: 'n/a',
|
||||||
},
|
},
|
||||||
builder: {
|
builder: {
|
||||||
title: 'Builder Sandbox',
|
title: 'Marketplace Project Editor',
|
||||||
subtitle: 'Configuration-only editing surface.',
|
subtitle: 'Configuration-only bootstrap editing surface.',
|
||||||
|
general: 'General',
|
||||||
branding: 'Branding',
|
branding: 'Branding',
|
||||||
|
theme: 'Theme',
|
||||||
|
header: 'Header',
|
||||||
|
footer: 'Footer',
|
||||||
|
homepage: 'Homepage',
|
||||||
|
widgets: 'Widgets',
|
||||||
|
marketplaceFeatures: 'Marketplace Features',
|
||||||
|
preview: 'Preview',
|
||||||
brandName: 'Brand Name',
|
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',
|
featureFlags: 'Feature Flags',
|
||||||
},
|
},
|
||||||
widgets: {
|
widgets: {
|
||||||
|
|||||||
@@ -398,10 +398,77 @@ export const hy: Translations = {
|
|||||||
notAvailable: 'չկա',
|
notAvailable: 'չկա',
|
||||||
},
|
},
|
||||||
builder: {
|
builder: {
|
||||||
title: 'Builder Sandbox',
|
title: 'Մարքեթփլեյսի նախագծի խմբագիր',
|
||||||
subtitle: 'Միայն կոնֆիգուրացիայի խմբագրման մակերես։',
|
subtitle: 'Միայն bootstrap կարգավորման խմբագրման մակերես։',
|
||||||
|
general: 'Ընդհանուր',
|
||||||
branding: 'Բրենդինգ',
|
branding: 'Բրենդինգ',
|
||||||
|
theme: 'Թեմա',
|
||||||
|
header: 'Header',
|
||||||
|
footer: 'Footer',
|
||||||
|
homepage: 'Գլխավոր էջ',
|
||||||
|
widgets: 'Վիջեթներ',
|
||||||
|
marketplaceFeatures: 'Մարքեթփլեյսի հնարավորություններ',
|
||||||
|
preview: 'Preview',
|
||||||
brandName: 'Բրենդի անվանում',
|
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',
|
featureFlags: 'Feature flags',
|
||||||
},
|
},
|
||||||
widgets: {
|
widgets: {
|
||||||
|
|||||||
@@ -398,10 +398,77 @@ export const ru: Translations = {
|
|||||||
notAvailable: 'н/д',
|
notAvailable: 'н/д',
|
||||||
},
|
},
|
||||||
builder: {
|
builder: {
|
||||||
title: 'Песочница Builder',
|
title: 'Редактор проекта маркетплейса',
|
||||||
subtitle: 'Поверхность редактирования только конфигурации.',
|
subtitle: 'Поверхность редактирования только bootstrap-конфигурации.',
|
||||||
|
general: 'Общие',
|
||||||
branding: 'Брендинг',
|
branding: 'Брендинг',
|
||||||
|
theme: 'Тема',
|
||||||
|
header: 'Хедер',
|
||||||
|
footer: 'Футер',
|
||||||
|
homepage: 'Главная страница',
|
||||||
|
widgets: 'Виджеты',
|
||||||
|
marketplaceFeatures: 'Функции маркетплейса',
|
||||||
|
preview: 'Превью',
|
||||||
brandName: 'Название бренда',
|
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: 'Фичи-флаги',
|
featureFlags: 'Фичи-флаги',
|
||||||
},
|
},
|
||||||
widgets: {
|
widgets: {
|
||||||
|
|||||||
@@ -398,8 +398,75 @@ export interface Translations {
|
|||||||
builder: {
|
builder: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
general: string;
|
||||||
branding: string;
|
branding: string;
|
||||||
|
theme: string;
|
||||||
|
header: string;
|
||||||
|
footer: string;
|
||||||
|
homepage: string;
|
||||||
|
widgets: string;
|
||||||
|
marketplaceFeatures: string;
|
||||||
|
preview: string;
|
||||||
brandName: 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;
|
featureFlags: string;
|
||||||
};
|
};
|
||||||
widgets: {
|
widgets: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CatalogConfig } from './catalog-config.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 { FooterConfig } from './footer-config.model';
|
||||||
|
import { HeaderConfig } from './header-config.model';
|
||||||
import { PlatformLayoutConfig } from './layout.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';
|
||||||
@@ -29,6 +30,7 @@ export interface BootstrapConfig {
|
|||||||
localization: LocalizationConfig;
|
localization: LocalizationConfig;
|
||||||
seo: SeoConfig;
|
seo: SeoConfig;
|
||||||
permissions: PermissionsConfig;
|
permissions: PermissionsConfig;
|
||||||
|
header?: HeaderConfig;
|
||||||
catalog?: CatalogConfig;
|
catalog?: CatalogConfig;
|
||||||
layout?: PlatformLayoutConfig;
|
layout?: PlatformLayoutConfig;
|
||||||
navigation: NavigationConfig;
|
navigation: NavigationConfig;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export interface FeatureFlagsConfig {
|
|||||||
wishlist: boolean;
|
wishlist: boolean;
|
||||||
compare: boolean;
|
compare: boolean;
|
||||||
reviews: boolean;
|
reviews: boolean;
|
||||||
|
questions: boolean;
|
||||||
|
comments: boolean;
|
||||||
|
recommendations: boolean;
|
||||||
blog: boolean;
|
blog: boolean;
|
||||||
chat: boolean;
|
chat: boolean;
|
||||||
analytics: boolean;
|
analytics: boolean;
|
||||||
|
|||||||
@@ -7,9 +7,18 @@ export interface FooterPaymentIconConfig {
|
|||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FooterSocialLinkConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FooterConfig {
|
export interface FooterConfig {
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
paymentIcons?: FooterPaymentIconConfig[];
|
paymentIcons?: FooterPaymentIconConfig[];
|
||||||
copyrightText?: string | LocalizedTextContent;
|
copyrightText?: string | LocalizedTextContent;
|
||||||
legalPageKeys?: string[];
|
legalPageKeys?: string[];
|
||||||
|
staticPageKeys?: string[];
|
||||||
|
socialLinks?: FooterSocialLinkConfig[];
|
||||||
}
|
}
|
||||||
23
src/app/shared/models/config/header-config.model.ts
Normal file
23
src/app/shared/models/config/header-config.model.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ export * from './catalog-config.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 './footer-config.model';
|
||||||
|
export * from './header-config.model';
|
||||||
export * from './layout.model';
|
export * from './layout.model';
|
||||||
export * from './localization.model';
|
export * from './localization.model';
|
||||||
export * from './navigation.model';
|
export * from './navigation.model';
|
||||||
|
|||||||
Reference in New Issue
Block a user