feat(builder): add project editor
This commit is contained in:
@@ -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()}`]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user