- PreviewHighlightService + appHighlightSource directive: shared hover/focus bridge between editor fields and the schematic live preview. - BuilderLivePreviewComponent: in-page schematic homepage render (header/hero/blocks/footer) reading the same bootstrap the sections mutate, highlighting the area matching the active field. - VisualLayoutPickerComponent: card-based layout picker (ControlValueAccessor, same shape as app-select) replacing the raw layout <select> in homepage-section. - app-form-field gains optional usedBy/usedByLabel inputs for the "Where is this used?" helper text, wired into aria-describedby. - Wired homepage/branding/theme sections with highlight sources + usedBy hints; live preview panel shown in project-editor-page for those three sections. - All new copy added as translation keys (en/ru/hy).
203 lines
9.1 KiB
TypeScript
203 lines
9.1 KiB
TypeScript
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
|
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
|
import { TranslateService } from '../../../i18n/translate.service';
|
|
import { InputComponent } from '../../../shared/ui/input/input.component';
|
|
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
|
|
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
|
|
import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
|
|
import { HomepageOverviewComponent } from './homepage/homepage-overview.component';
|
|
import { SectionConfig } from '../../../shared/models/config';
|
|
import { IconComponent } from '../../../shared/ui/icon/icon.component';
|
|
import { AppIconName } from '../../../shared/ui/icon/icon-registry';
|
|
import { ConfirmDialogComponent } from '../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
|
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
|
import { VisualLayoutPickerComponent, VisualLayoutOption } from '../../../shared/ui/visual-layout-picker/visual-layout-picker.component';
|
|
import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive';
|
|
|
|
export interface LayoutStrategyOption {
|
|
value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split';
|
|
labelKey: string;
|
|
descKey: string;
|
|
}
|
|
|
|
/** Merchant-facing catalog of the block types an admin can add to the homepage. Not the widget manifest (that carries technical settingsSchema) - this is presentation-only, matching the pattern already used in widgets-section.component.ts. */
|
|
interface BlockCatalogEntry {
|
|
type: string;
|
|
icon: AppIconName;
|
|
labelKey: string;
|
|
descKey: string;
|
|
defaultLayout: LayoutStrategyOption['value'];
|
|
}
|
|
|
|
const BLOCK_CATALOG: BlockCatalogEntry[] = [
|
|
{ type: 'hero', icon: 'image', labelKey: 'builder.blockHero', descKey: 'builder.blockHeroDesc', defaultLayout: 'hero' },
|
|
{ type: 'categories', icon: 'layoutGrid', labelKey: 'builder.blockCategories', descKey: 'builder.blockCategoriesDesc', defaultLayout: 'grid' },
|
|
{ type: 'product-collection', icon: 'package', labelKey: 'builder.blockFeaturedProducts', descKey: 'builder.blockFeaturedProductsDesc', defaultLayout: 'grid' },
|
|
{ type: 'product-carousel', icon: 'images', labelKey: 'builder.blockProductCarousel', descKey: 'builder.blockProductCarouselDesc', defaultLayout: 'carousel' },
|
|
{ type: 'recently-viewed', icon: 'history', labelKey: 'builder.blockRecentlyViewed', descKey: 'builder.blockRecentlyViewedDesc', defaultLayout: 'grid' },
|
|
{ type: 'banner', icon: 'megaphone', labelKey: 'builder.blockBanner', descKey: 'builder.blockBannerDesc', defaultLayout: 'split' },
|
|
{ type: 'partners', icon: 'verified', labelKey: 'builder.blockPartners', descKey: 'builder.blockPartnersDesc', defaultLayout: 'stack' },
|
|
{ type: 'html', icon: 'code', labelKey: 'builder.blockCustomHtml', descKey: 'builder.blockCustomHtmlDesc', defaultLayout: 'stack' },
|
|
];
|
|
|
|
const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
|
|
|
|
@Component({
|
|
selector: 'app-project-editor-homepage-section',
|
|
standalone: true,
|
|
imports: [DragDropModule, FormsModule, TranslatePipe, InputComponent, SectionCardComponent, ToggleComponent, EmptyStateComponent, HomepageOverviewComponent, IconComponent, ConfirmDialogComponent, FormFieldComponent, VisualLayoutPickerComponent, HighlightSourceDirective],
|
|
templateUrl: './homepage-section.component.html',
|
|
styleUrls: ['./section.shared.scss', './homepage-section.component.scss'],
|
|
changeDetection: ChangeDetectionStrategy.OnPush
|
|
})
|
|
export class ProjectEditorHomepageSectionComponent {
|
|
private readonly facade = inject(ProjectEditorFacade);
|
|
private readonly translate = inject(TranslateService);
|
|
readonly homePage = this.facade.homepagePage;
|
|
readonly fieldError = (key: string): string | null => {
|
|
const messageKey = this.facade.fieldError(key);
|
|
return messageKey ? this.translate.t(messageKey) : null;
|
|
};
|
|
readonly sections = computed(() => [...(this.homePage()?.sections ?? [])].sort((a, b) => a.order - b.order));
|
|
|
|
readonly blockCatalog = BLOCK_CATALOG;
|
|
|
|
readonly layoutStrategyOptions: LayoutStrategyOption[] = [
|
|
{ value: 'stack', labelKey: 'builder.layoutStrategyStack', descKey: 'builder.layoutStrategyStackDesc' },
|
|
{ value: 'grid', labelKey: 'builder.layoutStrategyGrid', descKey: 'builder.layoutStrategyGridDesc' },
|
|
{ value: 'hero', labelKey: 'builder.layoutStrategyHero', descKey: 'builder.layoutStrategyHeroDesc' },
|
|
{ value: 'carousel', labelKey: 'builder.layoutStrategyCarousel', descKey: 'builder.layoutStrategyCarouselDesc' },
|
|
{ value: 'split', labelKey: 'builder.layoutStrategySplit', descKey: 'builder.layoutStrategySplitDesc' },
|
|
];
|
|
|
|
/** Card icons for the visual layout picker - purely decorative shorthand for each strategy's shape. */
|
|
private readonly layoutStrategyIcons: Record<LayoutStrategyOption['value'], AppIconName> = {
|
|
stack: 'list',
|
|
grid: 'layoutGrid',
|
|
hero: 'image',
|
|
carousel: 'images',
|
|
split: 'table',
|
|
};
|
|
|
|
readonly layoutStrategyPickerOptions: VisualLayoutOption[] = this.layoutStrategyOptions.map(option => ({
|
|
value: option.value,
|
|
labelKey: option.labelKey,
|
|
descKey: option.descKey,
|
|
icon: this.layoutStrategyIcons[option.value],
|
|
}));
|
|
|
|
blockLabel(type: string): string {
|
|
const entry = BLOCK_BY_TYPE.get(type);
|
|
return entry ? this.translate.t(entry.labelKey) : type;
|
|
}
|
|
|
|
blockDescription(type: string): string {
|
|
const entry = BLOCK_BY_TYPE.get(type);
|
|
return entry ? this.translate.t(entry.descKey) : '';
|
|
}
|
|
|
|
blockIcon(type: string): AppIconName {
|
|
return BLOCK_BY_TYPE.get(type)?.icon ?? 'stop';
|
|
}
|
|
|
|
drop(event: CdkDragDrop<unknown[]>): void {
|
|
const sections = [...this.sections()];
|
|
moveItemInArray(sections, event.previousIndex, event.currentIndex);
|
|
this.replaceSections(sections.map((section, index) => ({ ...section, order: index + 1 })));
|
|
}
|
|
|
|
/** Keyboard-operable fallback for the drag-and-drop block reorder above (WCAG 2.1.1). */
|
|
moveBlock(sectionId: string, direction: -1 | 1): void {
|
|
const sections = [...this.sections()];
|
|
const index = sections.findIndex(section => section.id === sectionId);
|
|
const targetIndex = index + direction;
|
|
if (index === -1 || targetIndex < 0 || targetIndex >= sections.length) {
|
|
return;
|
|
}
|
|
moveItemInArray(sections, index, targetIndex);
|
|
this.replaceSections(sections.map((section, i) => ({ ...section, order: i + 1 })));
|
|
}
|
|
|
|
updateSection(sectionId: string, field: 'visible' | 'type', value: unknown): void {
|
|
this.replaceSections(this.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.replaceSections(this.sections().map(section => section.id !== sectionId ? section : ({
|
|
...section,
|
|
layout: {
|
|
...section.layout,
|
|
[key]: key === 'columns' ? Number(value) || 1 : value,
|
|
}
|
|
})));
|
|
}
|
|
|
|
addBlock(type: string): void {
|
|
const entry = BLOCK_BY_TYPE.get(type);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
const id = `section-${type}-${Date.now()}`;
|
|
const widgetId = `widget-${type}-${Date.now()}`;
|
|
const newSection: SectionConfig = {
|
|
id,
|
|
type,
|
|
order: this.sections().length + 1,
|
|
layout: { strategy: entry.defaultLayout, columns: 1, gap: '1.5rem', align: 'stretch' },
|
|
visibility: { desktop: true, tablet: true, mobile: true },
|
|
visible: true,
|
|
widgets: [{
|
|
id: widgetId,
|
|
type,
|
|
version: '1.0.0',
|
|
order: 1,
|
|
visibility: { desktop: true, tablet: true, mobile: true },
|
|
visible: true,
|
|
props: {},
|
|
}],
|
|
};
|
|
this.replaceSections([...this.sections(), newSection]);
|
|
}
|
|
|
|
duplicateBlock(sectionId: string): void {
|
|
const source = this.sections().find(section => section.id === sectionId);
|
|
if (!source) {
|
|
return;
|
|
}
|
|
const suffix = Date.now();
|
|
const copy: SectionConfig = {
|
|
...source,
|
|
id: `${source.id}-copy-${suffix}`,
|
|
order: this.sections().length + 1,
|
|
widgets: source.widgets.map(widget => ({ ...widget, id: `${widget.id}-copy-${suffix}` })),
|
|
};
|
|
this.replaceSections([...this.sections(), copy]);
|
|
}
|
|
|
|
readonly pendingRemoveBlockId = signal<string | null>(null);
|
|
|
|
removeBlock(sectionId: string): void {
|
|
this.pendingRemoveBlockId.set(sectionId);
|
|
}
|
|
|
|
confirmRemoveBlock(): void {
|
|
const sectionId = this.pendingRemoveBlockId();
|
|
this.pendingRemoveBlockId.set(null);
|
|
if (!sectionId) return;
|
|
this.replaceSections(this.sections().filter(section => section.id !== sectionId));
|
|
}
|
|
|
|
private replaceSections(sections: SectionConfig[]): void {
|
|
this.facade.updateBootstrap(current => ({
|
|
...current,
|
|
pages: current.pages.map(page => page.id !== this.homePage()?.id ? page : ({ ...page, sections }))
|
|
}));
|
|
}
|
|
}
|