From 42f11dd8c0b34702872f37b340ecc7edfce4e13d Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Mon, 27 Jul 2026 10:00:42 +0400 Subject: [PATCH] feat: Page Editor UX Phase 1 - live preview, hover mapping, visual layout picker - 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 - @for (option of layoutStrategyOptions; track option.value) { - - } - - - + diff --git a/src/app/features/project-editor/sections/homepage-section.component.ts b/src/app/features/project-editor/sections/homepage-section.component.ts index 2379eda..07a93db 100644 --- a/src/app/features/project-editor/sections/homepage-section.component.ts +++ b/src/app/features/project-editor/sections/homepage-section.component.ts @@ -13,6 +13,9 @@ 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'; @@ -45,7 +48,7 @@ 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], + 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 @@ -70,9 +73,21 @@ export class ProjectEditorHomepageSectionComponent { { value: 'split', labelKey: 'builder.layoutStrategySplit', descKey: 'builder.layoutStrategySplitDesc' }, ]; - layoutStrategyDescKey(strategy: string | undefined): string { - return this.layoutStrategyOptions.find(option => option.value === strategy)?.descKey ?? ''; - } + /** Card icons for the visual layout picker - purely decorative shorthand for each strategy's shape. */ + private readonly layoutStrategyIcons: Record = { + 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); diff --git a/src/app/features/project-editor/sections/theme-section.component.html b/src/app/features/project-editor/sections/theme-section.component.html index 000a1aa..9428427 100644 --- a/src/app/features/project-editor/sections/theme-section.component.html +++ b/src/app/features/project-editor/sections/theme-section.component.html @@ -1,9 +1,23 @@ @if (bootstrap(); as bootstrap) {
- + - + diff --git a/src/app/features/project-editor/sections/theme-section.component.ts b/src/app/features/project-editor/sections/theme-section.component.ts index 5cce9ea..e932399 100644 --- a/src/app/features/project-editor/sections/theme-section.component.ts +++ b/src/app/features/project-editor/sections/theme-section.component.ts @@ -8,6 +8,7 @@ import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.com import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component'; import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component'; import { ColorPickerComponent } from '../../../shared/ui/color-picker/color-picker.component'; +import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive'; export interface ThemeModeOption { value: 'light' | 'dark' | 'system'; @@ -24,7 +25,7 @@ export interface SiteLayoutOption { @Component({ selector: 'app-project-editor-theme-section', standalone: true, - imports: [FormsModule, TranslatePipe, FormFieldComponent, SectionCardComponent, SelectComponent, ColorPickerComponent], + imports: [FormsModule, TranslatePipe, FormFieldComponent, SectionCardComponent, SelectComponent, ColorPickerComponent, HighlightSourceDirective], templateUrl: './theme-section.component.html', styleUrls: ['./section.shared.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 3ab258c..61ac50f 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -868,6 +868,18 @@ export const en: Translations = { homepageEmptyTitle: 'No homepage configured', homepageEmptyDesc: 'Your marketplace does not have a homepage set up yet. Contact support to get one created.', homepageBlocksHint: 'These are the blocks customers see on your homepage, top to bottom. Drag the handle to reorder.', + livePreviewTitle: 'Live preview', + livePreviewHint: 'Hover a field to see what it changes.', + livePreviewEmpty: 'Nothing to preview yet.', + livePreviewButtonLabel: 'Shop now', + usedByLabel: 'Used by:', + usedByHeader: 'Header', + usedByFooter: 'Footer', + usedBySeo: 'Search results', + usedByButtons: 'Buttons', + usedByLinks: 'Links', + usedByBadges: 'Badges', + usedByPageBackground: 'Page background', addBlockHint: 'Add a block to your homepage:', dragToReorder: 'Drag to reorder', blockRemoveConfirm: 'Remove this block from the homepage?', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index c8e52a4..5775e5e 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -868,6 +868,18 @@ export const hy: Translations = { homepageEmptyTitle: 'Գլխավոր էջը կարգավորված չէ', homepageEmptyDesc: 'Ձեր խանութն դեռ չունի կարգավորված գլխավոր էջ։ Ստեղծելու համար դիմեք աջակցության թիմին։', homepageBlocksHint: 'Սրանք են բլոկները, որոնք գնորդները տեսնում են գլխավոր էջում վերևից ներքև։ Քաշեք բռնակից՝ կարգը փոխելու համար։', + livePreviewTitle: 'Կենդանի նախադիտում', + livePreviewHint: 'Անցկացրեք դաշտի վրա՝ տեսնելու համար, թե ինչ է այն փոխում։', + livePreviewEmpty: 'Դեռ ցուցադրելու բան չկա։', + livePreviewButtonLabel: 'Գնումներ', + usedByLabel: 'Օգտագործվում է՝', + usedByHeader: 'Վերնագիր', + usedByFooter: 'Ստորագիր', + usedBySeo: 'Որոնման արդյունքներ', + usedByButtons: 'Կոճակներ', + usedByLinks: 'Հղումներ', + usedByBadges: 'Կրծքանշաններ', + usedByPageBackground: 'Էջի ֆոն', addBlockHint: 'Ավելացնել բլոկ գլխավոր էջում.', dragToReorder: 'Քաշեք՝ կարգը փոխելու համար', blockRemoveConfirm: 'Հեռացնե՞լ այս բլոկը գլխավոր էջից։', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 62418c5..29fbb8c 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -868,6 +868,18 @@ export const ru: Translations = { homepageEmptyTitle: 'Главная страница не настроена', homepageEmptyDesc: 'В вашем магазине пока не настроена главная страница. Обратитесь в поддержку, чтобы её создать.', homepageBlocksHint: 'Это блоки, которые покупатели видят на главной странице сверху вниз. Перетаскивайте за ручку, чтобы изменить порядок.', + livePreviewTitle: 'Живой предпросмотр', + livePreviewHint: 'Наведите курсор на поле, чтобы увидеть, что оно меняет.', + livePreviewEmpty: 'Пока нечего показать.', + livePreviewButtonLabel: 'В магазин', + usedByLabel: 'Используется в:', + usedByHeader: 'Шапка сайта', + usedByFooter: 'Подвал сайта', + usedBySeo: 'Результаты поиска', + usedByButtons: 'Кнопки', + usedByLinks: 'Ссылки', + usedByBadges: 'Значки', + usedByPageBackground: 'Фон страницы', addBlockHint: 'Добавить блок на главную страницу:', dragToReorder: 'Перетащите, чтобы изменить порядок', blockRemoveConfirm: 'Удалить этот блок с главной страницы?', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 714149f..44b4fd3 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -869,6 +869,18 @@ export interface Translations { homepageBlocksHint: string; addBlockHint: string; dragToReorder: string; + livePreviewTitle: string; + livePreviewHint: string; + livePreviewEmpty: string; + livePreviewButtonLabel: string; + usedByLabel: string; + usedByHeader: string; + usedByFooter: string; + usedBySeo: string; + usedByButtons: string; + usedByLinks: string; + usedByBadges: string; + usedByPageBackground: string; blockRemoveConfirm: string; blockHero: string; blockHeroDesc: string; diff --git a/src/app/shared/services/preview-highlight.service.ts b/src/app/shared/services/preview-highlight.service.ts new file mode 100644 index 0000000..9e44712 --- /dev/null +++ b/src/app/shared/services/preview-highlight.service.ts @@ -0,0 +1,17 @@ +import { Injectable, signal } from '@angular/core'; + +/** + * Cross-component hover/focus bridge between an editor field and the schematic + * live preview. A field marks itself with `appHighlightSource="logo"` and the + * preview marks the matching element with `appHighlightTarget="logo"`; this + * service is the shared signal both sides read/write so they don't need a + * direct reference to each other. + */ +@Injectable({ providedIn: 'root' }) +export class PreviewHighlightService { + readonly activeId = signal(null); + + set(id: string | null): void { + this.activeId.set(id); + } +} diff --git a/src/app/shared/ui/form-field/form-field.component.html b/src/app/shared/ui/form-field/form-field.component.html index 04d604c..bd8aa46 100644 --- a/src/app/shared/ui/form-field/form-field.component.html +++ b/src/app/shared/ui/form-field/form-field.component.html @@ -17,4 +17,15 @@ } @else if (hint()) {

{{ hint() }}

} + + @if (usedBy(); as targets) { + @if (targets.length) { +

+ @if (usedByLabel()) { + {{ usedByLabel() }} + } + {{ targets.join(', ') }} +

+ } + }
diff --git a/src/app/shared/ui/form-field/form-field.component.scss b/src/app/shared/ui/form-field/form-field.component.scss index 8b2a3c0..a4693e0 100644 --- a/src/app/shared/ui/form-field/form-field.component.scss +++ b/src/app/shared/ui/form-field/form-field.component.scss @@ -30,3 +30,14 @@ font-size: var(--font-size-sm, 0.8125rem); color: var(--error-color, #c0392b); } + +.app-form-field__used-by { + margin: 0; + font-size: var(--font-size-xs, 0.75rem); + color: var(--text-secondary, #6b7280); +} + +.app-form-field__used-by-label { + font-weight: var(--font-weight-semibold, 600); + margin-right: 0.25rem; +} diff --git a/src/app/shared/ui/form-field/form-field.component.ts b/src/app/shared/ui/form-field/form-field.component.ts index 3603568..1e32f47 100644 --- a/src/app/shared/ui/form-field/form-field.component.ts +++ b/src/app/shared/ui/form-field/form-field.component.ts @@ -24,10 +24,20 @@ export class FormFieldComponent implements FormFieldContext { readonly hint = input(null); readonly error = input(null); readonly required = input(false); + /** Optional "Where is this used?" list, e.g. ['Buttons', 'Links', 'Badges']. Already-translated strings. */ + readonly usedBy = input(null); + readonly usedByLabel = input(null); readonly fieldId = `app-form-field-${++nextFormFieldId}`; protected readonly hintId = `${this.fieldId}-hint`; protected readonly errorId = `${this.fieldId}-error`; + protected readonly usedById = `${this.fieldId}-used-by`; - readonly describedBy = computed(() => this.error() ? this.errorId : (this.hint() ? this.hintId : null)); + readonly describedBy = computed(() => { + const ids = [ + this.error() ? this.errorId : (this.hint() ? this.hintId : null), + this.usedBy()?.length ? this.usedById : null, + ].filter((id): id is string => !!id); + return ids.length ? ids.join(' ') : null; + }); } diff --git a/src/app/shared/ui/highlight-source/highlight-source.directive.ts b/src/app/shared/ui/highlight-source/highlight-source.directive.ts new file mode 100644 index 0000000..4f73e8e --- /dev/null +++ b/src/app/shared/ui/highlight-source/highlight-source.directive.ts @@ -0,0 +1,31 @@ +import { Directive, HostListener, inject, input } from '@angular/core'; +import { PreviewHighlightService } from '../../services/preview-highlight.service'; + +/** + * Attach to any field/control wrapper: `