diff --git a/docs/Project-Editor.md b/docs/Project-Editor.md new file mode 100644 index 0000000..5bad937 --- /dev/null +++ b/docs/Project-Editor.md @@ -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 diff --git a/docs/platform/00-overview.md b/docs/platform/00-overview.md index 4c0148a..2acb784 100644 --- a/docs/platform/00-overview.md +++ b/docs/platform/00-overview.md @@ -86,3 +86,8 @@ - Возвращать валидный bootstrap JSON для каждого tenant. - Поддерживать согласованные API-контракты каталога. - Обеспечивать обратную совместимость либо явно повышать schemaVersion. + +## Project Editor +- Внутренний Project Editor редактирует только bootstrap-конфигурацию маркетплейса. +- Editor не управляет товарами, категориями, заказами или аналитикой. +- Первичная версия поддерживает секции general, branding, theme, header, footer, homepage, widgets, marketplace features и preview. diff --git a/docs/platform/02-bootstrap-json-spec.md b/docs/platform/02-bootstrap-json-spec.md index 3b19709..95d64c8 100644 --- a/docs/platform/02-bootstrap-json-spec.md +++ b/docs/platform/02-bootstrap-json-spec.md @@ -171,3 +171,9 @@ Bootstrap JSON является главным конфигурационным - Отдавать tenant-specific bootstrap.json. - Поддерживать schemaVersion и changelog контракта. - Не включать 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. diff --git a/docs/platform/05-widget-system.md b/docs/platform/05-widget-system.md index 8eb8909..b0e1093 100644 --- a/docs/platform/05-widget-system.md +++ b/docs/platform/05-widget-system.md @@ -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 ## Назначение diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 4345f39..8e41226 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -39,6 +39,15 @@ const coreRoutes: Routes = [ path: 'search', loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent) }, + { + path: 'builder', + loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent) + }, + { + path: 'project-editor', + redirectTo: 'builder', + pathMatch: 'full' + }, { path: 'wishlist', loadComponent: () => import('./features/website/user-experience/wishlist/containers/wishlist-page.component').then(m => m.WishlistPageComponent) diff --git a/src/app/components/footer/footer.component.ts b/src/app/components/footer/footer.component.ts index 9d33f68..415d760 100644 --- a/src/app/components/footer/footer.component.ts +++ b/src/app/components/footer/footer.component.ts @@ -1,11 +1,11 @@ -import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; -import { take } from 'rxjs/operators'; +import { Component, ChangeDetectionStrategy, effect, inject, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { TranslatePipe } from '../../i18n/translate.pipe'; import { LogoComponent } from '../logo/logo.component'; import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade'; import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { FooterPaymentIcon, FooterResolvedGroup, FooterResolverService } from '../../core/config/footer-resolver.service'; +import { ConfigService } from '../../core/config/config.service'; @Component({ selector: 'app-footer', @@ -19,22 +19,29 @@ export class FooterComponent { readonly paymentIcons = signal([]); readonly copyrightText = signal(''); + private readonly configService = inject(ConfigService); + constructor( private readonly uiRuntime: UiRuntimeFacade, private readonly footerResolver: FooterResolverService ) { - this.footerResolver.resolveFooterModel().pipe(take(1)).subscribe({ - next: model => { - this.footerGroups.set(model.groups); - this.paymentIcons.set(model.paymentIcons); - this.copyrightText.set(model.copyrightText); - }, - error: () => { + effect(() => { + this.configService.bootstrapRevision(); + const bootstrap = this.configService.getBootstrapSnapshot(); + if (!bootstrap) { this.footerGroups.set([]); this.paymentIcons.set([]); this.copyrightText.set(''); + return; } + + const model = this.footerResolver.resolveFooterModelFromBootstrap(bootstrap); + this.footerGroups.set(model.groups); + this.paymentIcons.set(model.paymentIcons); + this.copyrightText.set(model.copyrightText); }); + + this.configService.loadBootstrap().subscribe(); } currentYear = new Date().getFullYear(); diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html index a5f1002..d0e9a70 100644 --- a/src/app/components/header/header.component.html +++ b/src/app/components/header/header.component.html @@ -2,9 +2,11 @@
- + @if (headerConfig().showLogo) { + + }
+ @if (headerConfig().showSearch) {
+ } + @if (headerConfig().showSearch) { + }
- @if (userExperienceConfig.wishlist.enabled) { + @if (headerConfig().showWishlist && userExperienceConfig().wishlist.enabled) { } - @if (userExperienceConfig.compare.enabled) { + @if (headerConfig().showCompare && userExperienceConfig().compare.enabled) {
diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts index b0b2379..17362ee 100644 --- a/src/app/components/header/header.component.ts +++ b/src/app/components/header/header.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, Renderer2, inject, DOCUMENT } from '@angular/core'; +import { Component, ChangeDetectionStrategy, Renderer2, inject, DOCUMENT, computed } from '@angular/core'; import { Router, RouterLink, RouterLinkActive } from '@angular/router'; import { CartService } from '../../services/cart.service'; import { LanguageService } from '../../services/language.service'; @@ -10,7 +10,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe'; import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade'; import { UserExperienceFacade } from '../../facades/platform/user-experience.facade'; import { ConfigService } from '../../core/config/config.service'; -import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config'; +import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config'; @Component({ selector: 'app-header', @@ -33,7 +33,8 @@ export class HeaderComponent { readonly wishlistCount = this.uxFacade.wishlistCount; readonly compareCount = this.uxFacade.compareCount; - readonly userExperienceConfig = this.resolveUserExperienceConfig(); + readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig()); + readonly headerConfig = computed(() => this.resolveHeaderConfig()); constructor(private cartService: CartService, private router: Router) { this.cartItemCount = this.cartService.itemCount; @@ -127,6 +128,7 @@ export class HeaderComponent { } private resolveUserExperienceConfig() { + this.configService.bootstrapRevision(); const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience ?? {}; return { @@ -142,6 +144,16 @@ export class HeaderComponent { } }; } + + private resolveHeaderConfig() { + this.configService.bootstrapRevision(); + const raw = (this.configService.getBootstrapSnapshot() as any)?.header ?? {}; + + return { + ...DEFAULT_HEADER_CONFIG, + ...raw, + }; + } } diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index b722d39..3c2b6af 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -1,5 +1,5 @@ -import { Injectable, inject } from '@angular/core'; -import { Observable, throwError } from 'rxjs'; +import { Injectable, inject, signal } from '@angular/core'; +import { Observable, of, throwError } from 'rxjs'; import { catchError, shareReplay, tap } from 'rxjs/operators'; import { BootstrapConfig } from '../../shared/models/config'; import { CONFIG_PROVIDER } from './config-provider.token'; @@ -10,12 +10,20 @@ export class ConfigService { private bootstrapSnapshot: BootstrapConfig | null = null; private bootstrap$?: Observable; + private readonly revisionState = signal(0); + + readonly bootstrapRevision = this.revisionState.asReadonly(); loadBootstrap(forceRefresh: boolean = false): Observable { + if (this.bootstrapSnapshot && !forceRefresh && this.bootstrap$) { + return this.bootstrap$; + } + if (!this.bootstrap$ || forceRefresh) { this.bootstrap$ = this.provider.loadBootstrap().pipe( tap(config => { this.bootstrapSnapshot = config; + this.revisionState.update(value => value + 1); }), shareReplay(1), catchError(error => { @@ -32,4 +40,11 @@ export class ConfigService { getBootstrapSnapshot(): BootstrapConfig | null { return this.bootstrapSnapshot; } + + applyBootstrapOverride(next: BootstrapConfig): void { + const cloned = JSON.parse(JSON.stringify(next)) as BootstrapConfig; + this.bootstrapSnapshot = cloned; + this.bootstrap$ = of(cloned); + this.revisionState.update(value => value + 1); + } } diff --git a/src/app/core/config/footer-resolver.service.ts b/src/app/core/config/footer-resolver.service.ts index b566966..9c75f59 100644 --- a/src/app/core/config/footer-resolver.service.ts +++ b/src/app/core/config/footer-resolver.service.ts @@ -47,14 +47,18 @@ export class FooterResolverService { resolveFooterModel(): Observable { return this.configService.loadBootstrap().pipe( - map((bootstrap) => ({ - groups: this.resolveFooterGroups(bootstrap), - paymentIcons: this.resolvePaymentIcons(bootstrap.footer), - copyrightText: this.resolveCopyrightText(bootstrap) - })) + map((bootstrap) => this.resolveFooterModelFromBootstrap(bootstrap)) ); } + resolveFooterModelFromBootstrap(bootstrap: BootstrapConfig): FooterResolvedModel { + return { + groups: this.resolveFooterGroups(bootstrap), + paymentIcons: this.resolvePaymentIcons(bootstrap.footer), + copyrightText: this.resolveCopyrightText(bootstrap) + }; + } + private resolveFooterGroups(bootstrap: BootstrapConfig): FooterResolvedGroup[] { const footer = bootstrap.navigation?.footer ?? []; const lang = this.languageService.currentLanguage(); diff --git a/src/app/core/runtime/platform-runtime.service.ts b/src/app/core/runtime/platform-runtime.service.ts index b943d9d..b0f233d 100644 --- a/src/app/core/runtime/platform-runtime.service.ts +++ b/src/app/core/runtime/platform-runtime.service.ts @@ -5,6 +5,7 @@ import { BrandingEngineService } from '../../theme/runtime/branding-engine.servi import { ThemeEngineService } from '../../theme/runtime/theme-engine.service'; import { WidgetRegistryBootstrapService } from '../../widgets/registry/widget-registry.bootstrap.service'; import { PlatformRuntimeStateService } from './platform-runtime-state.service'; +import { BootstrapConfig } from '../../shared/models/config'; @Injectable({ providedIn: 'root' }) export class PlatformRuntimeService { @@ -55,4 +56,22 @@ export class PlatformRuntimeService { } }); } + + reloadFromBootstrap(bootstrap: BootstrapConfig): void { + this.configService.applyBootstrapOverride(bootstrap); + this.widgetRegistryBootstrap.registerFromManifest().subscribe({ + next: () => this.applyRuntimeBootstrap(bootstrap), + error: () => this.applyRuntimeBootstrap(bootstrap), + }); + } + + private applyRuntimeBootstrap(bootstrap: BootstrapConfig): void { + this.themeEngine.applyTheme(bootstrap.theme); + this.brandingEngine.applyBranding(bootstrap.branding); + this.runtimeState.markInitialized({ + localization: bootstrap.localization, + featureFlags: bootstrap.featureFlags, + permissions: bootstrap.permissions + }); + } } diff --git a/src/app/facades/runtime/ui-runtime.facade.ts b/src/app/facades/runtime/ui-runtime.facade.ts index 3ae0e07..07b89b8 100644 --- a/src/app/facades/runtime/ui-runtime.facade.ts +++ b/src/app/facades/runtime/ui-runtime.facade.ts @@ -1,6 +1,6 @@ -import { Injectable, signal } from '@angular/core'; -import { take } from 'rxjs/operators'; +import { Injectable, effect, signal } from '@angular/core'; import { ConfigService } from '../../core/config/config.service'; +import { BootstrapConfig } from '../../shared/models/config'; interface UiRuntimeState { marketplaceName: string; @@ -21,17 +21,25 @@ export class UiRuntimeFacade { }); constructor(private readonly configService: ConfigService) { - this.configService.loadBootstrap().pipe(take(1)).subscribe({ - next: (bootstrap) => { - this.state.set({ - marketplaceName: bootstrap.branding.brandName, - marketplaceDisplayName: bootstrap.branding.brandName, - logoUrl: bootstrap.branding.logoUrl, - contactEmail: bootstrap.branding.supportEmail ?? '', - themeId: bootstrap.theme.themeId - }); + effect(() => { + this.configService.bootstrapRevision(); + const bootstrap = this.configService.getBootstrapSnapshot(); + if (bootstrap) { + this.reloadFromBootstrap(bootstrap); } }); + + this.configService.loadBootstrap().subscribe(); + } + + reloadFromBootstrap(bootstrap: BootstrapConfig): void { + this.state.set({ + marketplaceName: bootstrap.branding.brandName, + marketplaceDisplayName: bootstrap.branding.brandName, + logoUrl: bootstrap.branding.logoUrl, + contactEmail: bootstrap.branding.supportEmail ?? bootstrap.company?.contacts?.email ?? '', + themeId: bootstrap.theme.themeId + }); } marketplaceName(): string { diff --git a/src/app/features/project-editor/components/project-editor-nav.component.html b/src/app/features/project-editor/components/project-editor-nav.component.html new file mode 100644 index 0000000..b3ef139 --- /dev/null +++ b/src/app/features/project-editor/components/project-editor-nav.component.html @@ -0,0 +1,5 @@ + diff --git a/src/app/features/project-editor/components/project-editor-nav.component.scss b/src/app/features/project-editor/components/project-editor-nav.component.scss new file mode 100644 index 0000000..459ff96 --- /dev/null +++ b/src/app/features/project-editor/components/project-editor-nav.component.scss @@ -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; +} diff --git a/src/app/features/project-editor/components/project-editor-nav.component.ts b/src/app/features/project-editor/components/project-editor-nav.component.ts new file mode 100644 index 0000000..2e59965 --- /dev/null +++ b/src/app/features/project-editor/components/project-editor-nav.component.ts @@ -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(); + + 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' }, + ]; +} diff --git a/src/app/features/project-editor/facade/project-editor.facade.ts b/src/app/features/project-editor/facade/project-editor.facade.ts new file mode 100644 index 0000000..d1fac23 --- /dev/null +++ b/src/app/features/project-editor/facade/project-editor.facade.ts @@ -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({ + 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; + }) + }; + } +} diff --git a/src/app/features/project-editor/models/project-editor.model.ts b/src/app/features/project-editor/models/project-editor.model.ts new file mode 100644 index 0000000..5a5af8d --- /dev/null +++ b/src/app/features/project-editor/models/project-editor.model.ts @@ -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 = { + showLogo: true, + showSearch: true, + showCategories: true, + showLanguages: true, + showCart: true, + showProfile: false, + showWishlist: true, + showCompare: true, + showRegion: true, +}; diff --git a/src/app/features/project-editor/pages/project-editor-page.component.html b/src/app/features/project-editor/pages/project-editor-page.component.html new file mode 100644 index 0000000..96e3508 --- /dev/null +++ b/src/app/features/project-editor/pages/project-editor-page.component.html @@ -0,0 +1,29 @@ +
+
+
+

{{ 'builder.title' | translate }}

+

{{ 'builder.subtitle' | translate }}

+
+ +
+ + @if (!bootstrap()) { +
+

{{ 'common.loading' | translate }}

+
+ } @else { +
+ @switch (activeSection()) { + @case ('general') { } + @case ('branding') { } + @case ('theme') { } + @case ('header') { } + @case ('footer') { } + @case ('homepage') { } + @case ('widgets') { } + @case ('features') { } + @case ('preview') { } + } +
+ } +
diff --git a/src/app/features/project-editor/pages/project-editor-page.component.scss b/src/app/features/project-editor/pages/project-editor-page.component.scss new file mode 100644 index 0000000..bec0e1e --- /dev/null +++ b/src/app/features/project-editor/pages/project-editor-page.component.scss @@ -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; + } +} diff --git a/src/app/features/project-editor/pages/project-editor-page.component.ts b/src/app/features/project-editor/pages/project-editor-page.component.ts new file mode 100644 index 0000000..8c56591 --- /dev/null +++ b/src/app/features/project-editor/pages/project-editor-page.component.ts @@ -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(); + } +} diff --git a/src/app/features/project-editor/sections/branding-section.component.html b/src/app/features/project-editor/sections/branding-section.component.html new file mode 100644 index 0000000..09f0359 --- /dev/null +++ b/src/app/features/project-editor/sections/branding-section.component.html @@ -0,0 +1,23 @@ +@if (bootstrap(); as bootstrap) { +
+

{{ 'builder.branding' | translate }}

+
+ + + + +
+
+} diff --git a/src/app/features/project-editor/sections/branding-section.component.ts b/src/app/features/project-editor/sections/branding-section.component.ts new file mode 100644 index 0000000..90ebf72 --- /dev/null +++ b/src/app/features/project-editor/sections/branding-section.component.ts @@ -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(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 } } + })); + } +} diff --git a/src/app/features/project-editor/sections/features-section.component.html b/src/app/features/project-editor/sections/features-section.component.html new file mode 100644 index 0000000..ddeaf1a --- /dev/null +++ b/src/app/features/project-editor/sections/features-section.component.html @@ -0,0 +1,16 @@ +@if (bootstrap(); as bootstrap) { +
+

{{ 'builder.marketplaceFeatures' | translate }}

+
+ + + + + + + + + +
+
+} diff --git a/src/app/features/project-editor/sections/features-section.component.ts b/src/app/features/project-editor/sections/features-section.component.ts new file mode 100644 index 0000000..c793b04 --- /dev/null +++ b/src/app/features/project-editor/sections/features-section.component.ts @@ -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 + } + } + })); + } +} diff --git a/src/app/features/project-editor/sections/footer-section.component.html b/src/app/features/project-editor/sections/footer-section.component.html new file mode 100644 index 0000000..7b1162f --- /dev/null +++ b/src/app/features/project-editor/sections/footer-section.component.html @@ -0,0 +1,15 @@ +@if (bootstrap(); as bootstrap) { +
+

{{ 'builder.footer' | translate }}

+
+ + + + + + + + +
+
+} diff --git a/src/app/features/project-editor/sections/footer-section.component.ts b/src/app/features/project-editor/sections/footer-section.component.ts new file mode 100644 index 0000000..46e7c7a --- /dev/null +++ b/src/app/features/project-editor/sections/footer-section.component.ts @@ -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 } })); + } +} diff --git a/src/app/features/project-editor/sections/general-section.component.html b/src/app/features/project-editor/sections/general-section.component.html new file mode 100644 index 0000000..bff03a8 --- /dev/null +++ b/src/app/features/project-editor/sections/general-section.component.html @@ -0,0 +1,27 @@ +@if (bootstrap(); as bootstrap) { +
+

{{ 'builder.general' | translate }}

+
+ + + + + +
+
+} diff --git a/src/app/features/project-editor/sections/general-section.component.ts b/src/app/features/project-editor/sections/general-section.component.ts new file mode 100644 index 0000000..8e2d522 --- /dev/null +++ b/src/app/features/project-editor/sections/general-section.component.ts @@ -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 } + })); + } +} diff --git a/src/app/features/project-editor/sections/header-section.component.html b/src/app/features/project-editor/sections/header-section.component.html new file mode 100644 index 0000000..5b441f1 --- /dev/null +++ b/src/app/features/project-editor/sections/header-section.component.html @@ -0,0 +1,13 @@ +@if (bootstrap(); as bootstrap) { +
+

{{ 'builder.header' | translate }}

+
+ @for (item of items; track item.key) { + + } +
+
+} diff --git a/src/app/features/project-editor/sections/header-section.component.ts b/src/app/features/project-editor/sections/header-section.component.ts new file mode 100644 index 0000000..5ec4562 --- /dev/null +++ b/src/app/features/project-editor/sections/header-section.component.ts @@ -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]; + } +} diff --git a/src/app/features/project-editor/sections/homepage-section.component.html b/src/app/features/project-editor/sections/homepage-section.component.html new file mode 100644 index 0000000..9866977 --- /dev/null +++ b/src/app/features/project-editor/sections/homepage-section.component.html @@ -0,0 +1,17 @@ +@if (homePage()) { +
+

{{ 'builder.homepage' | translate }}

+
+ @for (section of sections(); track section.id) { +
+
+ {{ section.id }} + + + +
+
+ } +
+
+} diff --git a/src/app/features/project-editor/sections/homepage-section.component.ts b/src/app/features/project-editor/sections/homepage-section.component.ts new file mode 100644 index 0000000..9c6e087 --- /dev/null +++ b/src/app/features/project-editor/sections/homepage-section.component.ts @@ -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): 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, + } + })) + })) + })); + } +} diff --git a/src/app/features/project-editor/sections/preview-section.component.html b/src/app/features/project-editor/sections/preview-section.component.html new file mode 100644 index 0000000..9c76ab3 --- /dev/null +++ b/src/app/features/project-editor/sections/preview-section.component.html @@ -0,0 +1,19 @@ +
+

{{ 'builder.preview' | translate }}

+
+ + + +
+ @if (importError()) { +

{{ importError()! | translate }}

+ } + + +
diff --git a/src/app/features/project-editor/sections/preview-section.component.ts b/src/app/features/project-editor/sections/preview-section.component.ts new file mode 100644 index 0000000..8347cc3 --- /dev/null +++ b/src/app/features/project-editor/sections/preview-section.component.ts @@ -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(); + } +} diff --git a/src/app/features/project-editor/sections/section.shared.scss b/src/app/features/project-editor/sections/section.shared.scss new file mode 100644 index 0000000..636db07 --- /dev/null +++ b/src/app/features/project-editor/sections/section.shared.scss @@ -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; + } +} diff --git a/src/app/features/project-editor/sections/theme-section.component.html b/src/app/features/project-editor/sections/theme-section.component.html new file mode 100644 index 0000000..93e4fba --- /dev/null +++ b/src/app/features/project-editor/sections/theme-section.component.html @@ -0,0 +1,15 @@ +@if (bootstrap(); as bootstrap) { +
+

{{ 'builder.theme' | translate }}

+
+ + + + + + + + +
+
+} diff --git a/src/app/features/project-editor/sections/theme-section.component.ts b/src/app/features/project-editor/sections/theme-section.component.ts new file mode 100644 index 0000000..3d92a44 --- /dev/null +++ b/src/app/features/project-editor/sections/theme-section.component.ts @@ -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(key: K, value: string): void { + this.facade.updateBootstrap(current => ({ + ...current, + theme: { ...current.theme, palette: { ...current.theme.palette, [key]: value } } + })); + } +} diff --git a/src/app/features/project-editor/sections/widgets-section.component.html b/src/app/features/project-editor/sections/widgets-section.component.html new file mode 100644 index 0000000..406501c --- /dev/null +++ b/src/app/features/project-editor/sections/widgets-section.component.html @@ -0,0 +1,44 @@ +@if (widgets().length > 0) { +
+

{{ 'builder.widgets' | translate }}

+
+ @for (entry of widgets(); track entry.widget.id) { +
+

{{ entry.widget.type }} · {{ entry.widget.id }}

+ @switch (entry.widget.type) { + @case ('hero') { +
+ + + + +
+ } + @case ('categories') { +
+ + +
+ } + @case ('product-collection') { +
+ + + + + + +
+ } + @default { + + } + } +
+ } +
+
+} diff --git a/src/app/features/project-editor/sections/widgets-section.component.ts b/src/app/features/project-editor/sections/widgets-section.component.ts new file mode 100644 index 0000000..c521b6e --- /dev/null +++ b/src/app/features/project-editor/sections/widgets-section.component.ts @@ -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) => Record): 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 { + return JSON.stringify(props ?? {}, null, 2); + } +} diff --git a/src/app/features/project-editor/services/project-editor-io.service.ts b/src/app/features/project-editor/services/project-editor-io.service.ts new file mode 100644 index 0000000..3e975bb --- /dev/null +++ b/src/app/features/project-editor/services/project-editor-io.service.ts @@ -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; + } +} diff --git a/src/app/features/project-editor/services/project-editor-preview.service.ts b/src/app/features/project-editor/services/project-editor-preview.service.ts new file mode 100644 index 0000000..4fd075f --- /dev/null +++ b/src/app/features/project-editor/services/project-editor-preview.service.ts @@ -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()}`]); + } +} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index a6c65ec..f8b0c6a 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -398,10 +398,77 @@ export const en: Translations = { notAvailable: 'n/a', }, builder: { - title: 'Builder Sandbox', - subtitle: 'Configuration-only editing surface.', + title: 'Marketplace Project Editor', + subtitle: 'Configuration-only bootstrap editing surface.', + general: 'General', branding: 'Branding', + theme: 'Theme', + header: 'Header', + footer: 'Footer', + homepage: 'Homepage', + widgets: 'Widgets', + marketplaceFeatures: 'Marketplace Features', + preview: 'Preview', brandName: 'Brand Name', + marketplaceName: 'Marketplace Name', + domain: 'Domain', + descriptionLabel: 'Description', + defaultLanguage: 'Default Language', + supportedLanguages: 'Supported Languages', + logo: 'Logo', + smallLogo: 'Small Logo', + favicon: 'Favicon', + marketplaceTitle: 'Marketplace Title', + primaryColor: 'Primary Color', + secondaryColor: 'Secondary Color', + backgroundColor: 'Background', + surfaceColor: 'Surface', + textColor: 'Text', + successColor: 'Success', + warningColor: 'Warning', + dangerColor: 'Danger', + showLogo: 'Logo', + showSearch: 'Search', + showCategories: 'Categories', + showLanguages: 'Languages', + showCart: 'Cart', + showProfile: 'Profile', + showWishlist: 'Wishlist', + showCompare: 'Compare', + showRegion: 'Region', + companyName: 'Company Information', + address: 'Address', + phone: 'Phone', + email: 'Email', + copyright: 'Copyright', + paymentIcons: 'Payment Icons', + socialLinks: 'Social Links', + staticPages: 'Static Pages', + visible: 'Visible', + layoutLabel: 'Layout', + columns: 'Columns', + height: 'Height', + overlay: 'Overlay', + autoplay: 'Autoplay', + cardsPerRow: 'Cards Per Row', + filtersLabel: 'Filters', + showBadges: 'Show Badges', + showRating: 'Show Rating', + showPrice: 'Show Price', + widgetJson: 'Widget JSON', + reviews: 'Reviews', + questions: 'Questions', + comments: 'Comments', + recommendations: 'Recommendations', + recentlyViewed: 'Recently Viewed', + searchSuggestions: 'Search Suggestions', + searchHistory: 'Search History', + livePreview: 'Live Preview', + exportBootstrap: 'Export Bootstrap', + importBootstrap: 'Import Bootstrap', + exportedBootstrap: 'Exported Bootstrap', + importSource: 'Import Source', + importError: 'Import failed. Invalid bootstrap JSON.', featureFlags: 'Feature Flags', }, widgets: { diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 92376ea..19f177f 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -398,10 +398,77 @@ export const hy: Translations = { notAvailable: 'չկա', }, builder: { - title: 'Builder Sandbox', - subtitle: 'Միայն կոնֆիգուրացիայի խմբագրման մակերես։', + title: 'Մարքեթփլեյսի նախագծի խմբագիր', + subtitle: 'Միայն bootstrap կարգավորման խմբագրման մակերես։', + general: 'Ընդհանուր', branding: 'Բրենդինգ', + theme: 'Թեմա', + header: 'Header', + footer: 'Footer', + homepage: 'Գլխավոր էջ', + widgets: 'Վիջեթներ', + marketplaceFeatures: 'Մարքեթփլեյսի հնարավորություններ', + preview: 'Preview', brandName: 'Բրենդի անվանում', + marketplaceName: 'Մարքեթփլեյսի անվանում', + domain: 'Դոմեյն', + descriptionLabel: 'Նկարագրություն', + defaultLanguage: 'Լռելյայն լեզու', + supportedLanguages: 'Աջակցվող լեզուներ', + logo: 'Լոգո', + smallLogo: 'Փոքր լոգո', + favicon: 'Favicon', + marketplaceTitle: 'Մարքեթփլեյսի վերնագիր', + primaryColor: 'Հիմնական գույն', + secondaryColor: 'Երկրորդական գույն', + backgroundColor: 'Ֆոն', + surfaceColor: 'Մակերես', + textColor: 'Տեքստ', + successColor: 'Success', + warningColor: 'Warning', + dangerColor: 'Danger', + showLogo: 'Լոգո', + showSearch: 'Որոնում', + showCategories: 'Կատեգորիաներ', + showLanguages: 'Լեզուներ', + showCart: 'Զամբյուղ', + showProfile: 'Պրոֆիլ', + showWishlist: 'Ընտրյալներ', + showCompare: 'Համեմատում', + showRegion: 'Տարածաշրջան', + companyName: 'Ընկերության տվյալներ', + address: 'Հասցե', + phone: 'Հեռախոս', + email: 'Email', + copyright: 'Copyright', + paymentIcons: 'Վճարման icon-ներ', + socialLinks: 'Սոցիալական հղումներ', + staticPages: 'Ստատիկ էջեր', + visible: 'Տեսանելի', + layoutLabel: 'Layout', + columns: 'Սյուներ', + height: 'Բարձրություն', + overlay: 'Overlay', + autoplay: 'Autoplay', + cardsPerRow: 'Քարտեր մեկ շարքում', + filtersLabel: 'Ֆիլտրեր', + showBadges: 'Ցույց տալ badges', + showRating: 'Ցույց տալ վարկանիշ', + showPrice: 'Ցույց տալ գին', + widgetJson: 'Widget JSON', + reviews: 'Կարծիքներ', + questions: 'Հարցեր', + comments: 'Մեկնաբանություններ', + recommendations: 'Առաջարկություններ', + recentlyViewed: 'Վերջին դիտվածներ', + searchSuggestions: 'Որոնման առաջարկներ', + searchHistory: 'Որոնման պատմություն', + livePreview: 'Կենդանի preview', + exportBootstrap: 'Արտահանել bootstrap', + importBootstrap: 'Ներմուծել bootstrap', + exportedBootstrap: 'Արտահանված bootstrap', + importSource: 'Ներմուծման աղբյուր', + importError: 'Ներմուծումը ձախողվեց։ Սխալ bootstrap JSON։', featureFlags: 'Feature flags', }, widgets: { diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 15ce042..f2ccc5d 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -398,10 +398,77 @@ export const ru: Translations = { notAvailable: 'н/д', }, builder: { - title: 'Песочница Builder', - subtitle: 'Поверхность редактирования только конфигурации.', + title: 'Редактор проекта маркетплейса', + subtitle: 'Поверхность редактирования только bootstrap-конфигурации.', + general: 'Общие', branding: 'Брендинг', + theme: 'Тема', + header: 'Хедер', + footer: 'Футер', + homepage: 'Главная страница', + widgets: 'Виджеты', + marketplaceFeatures: 'Функции маркетплейса', + preview: 'Превью', brandName: 'Название бренда', + marketplaceName: 'Название маркетплейса', + domain: 'Домен', + descriptionLabel: 'Описание', + defaultLanguage: 'Язык по умолчанию', + supportedLanguages: 'Поддерживаемые языки', + logo: 'Логотип', + smallLogo: 'Малый логотип', + favicon: 'Favicon', + marketplaceTitle: 'Заголовок маркетплейса', + primaryColor: 'Основной цвет', + secondaryColor: 'Вторичный цвет', + backgroundColor: 'Фон', + surfaceColor: 'Поверхность', + textColor: 'Текст', + successColor: 'Success', + warningColor: 'Warning', + dangerColor: 'Danger', + showLogo: 'Логотип', + showSearch: 'Поиск', + showCategories: 'Категории', + showLanguages: 'Языки', + showCart: 'Корзина', + showProfile: 'Профиль', + showWishlist: 'Избранное', + showCompare: 'Сравнение', + showRegion: 'Регион', + companyName: 'Информация о компании', + address: 'Адрес', + phone: 'Телефон', + email: 'Email', + copyright: 'Копирайт', + paymentIcons: 'Иконки оплаты', + socialLinks: 'Социальные ссылки', + staticPages: 'Статические страницы', + visible: 'Видимость', + layoutLabel: 'Layout', + columns: 'Колонки', + height: 'Высота', + overlay: 'Overlay', + autoplay: 'Autoplay', + cardsPerRow: 'Карточек в ряд', + filtersLabel: 'Фильтры', + showBadges: 'Показывать badges', + showRating: 'Показывать рейтинг', + showPrice: 'Показывать цену', + widgetJson: 'JSON виджета', + reviews: 'Отзывы', + questions: 'Вопросы', + comments: 'Комментарии', + recommendations: 'Рекомендации', + recentlyViewed: 'Недавно просмотренные', + searchSuggestions: 'Поисковые подсказки', + searchHistory: 'История поиска', + livePreview: 'Живое превью', + exportBootstrap: 'Экспорт bootstrap', + importBootstrap: 'Импорт bootstrap', + exportedBootstrap: 'Экспортированный bootstrap', + importSource: 'Источник импорта', + importError: 'Импорт не удался. Некорректный JSON bootstrap.', featureFlags: 'Фичи-флаги', }, widgets: { diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index e66a789..becd7e8 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -398,8 +398,75 @@ export interface Translations { builder: { title: string; subtitle: string; + general: string; branding: string; + theme: string; + header: string; + footer: string; + homepage: string; + widgets: string; + marketplaceFeatures: string; + preview: string; brandName: string; + marketplaceName: string; + domain: string; + descriptionLabel: string; + defaultLanguage: string; + supportedLanguages: string; + logo: string; + smallLogo: string; + favicon: string; + marketplaceTitle: string; + primaryColor: string; + secondaryColor: string; + backgroundColor: string; + surfaceColor: string; + textColor: string; + successColor: string; + warningColor: string; + dangerColor: string; + showLogo: string; + showSearch: string; + showCategories: string; + showLanguages: string; + showCart: string; + showProfile: string; + showWishlist: string; + showCompare: string; + showRegion: string; + companyName: string; + address: string; + phone: string; + email: string; + copyright: string; + paymentIcons: string; + socialLinks: string; + staticPages: string; + visible: string; + layoutLabel: string; + columns: string; + height: string; + overlay: string; + autoplay: string; + cardsPerRow: string; + filtersLabel: string; + showBadges: string; + showRating: string; + showPrice: string; + widgetJson: string; + reviews: string; + questions: string; + comments: string; + recommendations: string; + recentlyViewed: string; + searchSuggestions: string; + searchHistory: string; + livePreview: string; + exportBootstrap: string; + importBootstrap: string; + exportedBootstrap: string; + importSource: string; + importError: string; featureFlags: string; }; widgets: { diff --git a/src/app/shared/models/config/bootstrap-config.model.ts b/src/app/shared/models/config/bootstrap-config.model.ts index 3e45bc9..654dd38 100644 --- a/src/app/shared/models/config/bootstrap-config.model.ts +++ b/src/app/shared/models/config/bootstrap-config.model.ts @@ -4,6 +4,7 @@ import { CatalogConfig } from './catalog-config.model'; import { CompanyConfig } from './company.model'; import { FeatureFlagsConfig } from './feature-flags.model'; import { FooterConfig } from './footer-config.model'; +import { HeaderConfig } from './header-config.model'; import { PlatformLayoutConfig } from './layout.model'; import { LocalizationConfig } from './localization.model'; import { NavigationConfig } from './navigation.model'; @@ -29,6 +30,7 @@ export interface BootstrapConfig { localization: LocalizationConfig; seo: SeoConfig; permissions: PermissionsConfig; + header?: HeaderConfig; catalog?: CatalogConfig; layout?: PlatformLayoutConfig; navigation: NavigationConfig; diff --git a/src/app/shared/models/config/feature-flags.model.ts b/src/app/shared/models/config/feature-flags.model.ts index da3fcc6..4d3758a 100644 --- a/src/app/shared/models/config/feature-flags.model.ts +++ b/src/app/shared/models/config/feature-flags.model.ts @@ -2,6 +2,9 @@ export interface FeatureFlagsConfig { wishlist: boolean; compare: boolean; reviews: boolean; + questions: boolean; + comments: boolean; + recommendations: boolean; blog: boolean; chat: boolean; analytics: boolean; diff --git a/src/app/shared/models/config/footer-config.model.ts b/src/app/shared/models/config/footer-config.model.ts index 0406ed7..162e998 100644 --- a/src/app/shared/models/config/footer-config.model.ts +++ b/src/app/shared/models/config/footer-config.model.ts @@ -7,9 +7,18 @@ export interface FooterPaymentIconConfig { height?: number; } +export interface FooterSocialLinkConfig { + id: string; + label: string; + url: string; + icon?: string; +} + export interface FooterConfig { logoUrl?: string; paymentIcons?: FooterPaymentIconConfig[]; copyrightText?: string | LocalizedTextContent; legalPageKeys?: string[]; + staticPageKeys?: string[]; + socialLinks?: FooterSocialLinkConfig[]; } \ No newline at end of file diff --git a/src/app/shared/models/config/header-config.model.ts b/src/app/shared/models/config/header-config.model.ts new file mode 100644 index 0000000..05f3e16 --- /dev/null +++ b/src/app/shared/models/config/header-config.model.ts @@ -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 = { + showLogo: true, + showSearch: true, + showCategories: true, + showLanguages: true, + showCart: true, + showProfile: false, + showWishlist: true, + showCompare: true, + showRegion: true, +}; diff --git a/src/app/shared/models/config/index.ts b/src/app/shared/models/config/index.ts index 19028cf..874a804 100644 --- a/src/app/shared/models/config/index.ts +++ b/src/app/shared/models/config/index.ts @@ -5,6 +5,7 @@ export * from './catalog-config.model'; export * from './company.model'; export * from './feature-flags.model'; export * from './footer-config.model'; +export * from './header-config.model'; export * from './layout.model'; export * from './localization.model'; export * from './navigation.model';