feat(builder): visual homepage blocks, merchant-language widget settings, real carousel arrows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

P0 user feedback: homepage builder showed raw section.id like 'section-hero'/'section-categories'; hero widget exposed 'full-bleed'/'boxed' and raw px/vh as free text with no explanation; Overlay/Autoplay toggles made no sense without a slides concept; product carousel widgets rendered arrows nowhere near a working carousel.

Homepage section (sections list -> visual blocks):
- Replaced raw section.id display with a merchant-facing block catalog (icon + name + one-line explanation) for hero/categories/featured-products/product-carousel/recently-viewed/banner/partners/custom-html
- Added block catalog picker to append new blocks (was fixed at whatever the seed data had - task asked 'what if we add manually? not fixed 3')
- Added duplicate and remove per block, alongside the existing drag-to-reorder
- Verified in browser: labels render correctly, add-block and duplicate both confirmed working end-to-end

Widgets section (hero widget):
- 'Layout' free-text replaced with a select (Full width / Boxed) instead of typing 'full-bleed'/'boxed' blind
- 'Height' free-text replaced with a select (Compact/Medium/Tall/Full screen) mapped to real vh values
- New Slides editor: title/subtitle pairs an admin can add/remove: this is the actual multi-slide data the Overlay/Autoplay toggles were referring to with nothing to point at before
- HeroWidgetData contract gains slides[]/autoplay; HeroWidgetComponent now renders a real rotator (dots, click-to-jump, autoplay interval) when more than one slide exists - previously autoplay/overlay props existed but there was no slideshow behavior anywhere to control

Carousel arrows root cause and fix:
- widget-manifest.json offers 'carousel' as a layout option for product-collection/product-carousel widgets, and the admin UI let you select it, but ProductCarouselWidgetComponent always rendered a static CSS grid regardless - there was no carousel implementation to have arrows in the first place
- Now renders a real horizontally-scrollable strip with working prev/next buttons (native scrollBy, disabled at each end) when section.layout.strategy === 'carousel'; falls back to the existing grid otherwise
- Confirmed src/app/components/items-carousel (a PrimeNG p-carousel) is dead code, not wired into any route or widget - not the source of the reported bug

New builder.* i18n keys (en/ru/hy), zero duplicate-key collisions verified via scan
This commit is contained in:
sdarbinyan
2026-07-19 14:14:29 +04:00
parent 726df0cee0
commit 71d5f4d320
14 changed files with 705 additions and 66 deletions

View File

@@ -4,34 +4,72 @@
@if (fieldError('pages'); as msg) {
<p class="editor-error">{{ msg }}</p>
}
<div cdkDropList class="sortable-list" (cdkDropListDropped)="drop($event)">
<p class="field-desc">{{ 'builder.homepageBlocksHint' | translate }}</p>
<div cdkDropList class="block-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 class="toggle-row">
<div class="block-card" cdkDrag [class.block-card--hidden]="section.visible === false">
<div class="block-card__handle" cdkDragHandle [attr.aria-label]="'builder.dragToReorder' | translate">
<span class="pi pi-bars" aria-hidden="true"></span>
</div>
<div class="block-card__preview">
<span class="pi {{ blockIcon(section.type) }}" aria-hidden="true"></span>
</div>
<div class="block-card__body">
<div class="block-card__title-row">
<strong>{{ blockLabel(section.type) }}</strong>
@if (section.visible === false) {
<span class="block-card__hidden-badge">{{ 'builder.widgetHidden' | translate }}</span>
}
</div>
<p class="block-card__desc">{{ blockDescription(section.type) }}</p>
<div class="editor-grid two compact">
<label>
<span>{{ 'builder.layoutStrategyLabel' | translate }}</span>
<small class="field-desc">{{ layoutStrategyDescKey(section.layout?.strategy || 'stack') | translate }}</small>
<select [ngModel]="section.layout?.strategy || 'stack'" (ngModelChange)="updateLayout(section.id, 'strategy', $event)">
@for (option of layoutStrategyOptions; track option.value) {
<option [value]="option.value">{{ option.labelKey | translate }}</option>
}
</select>
</label>
<label>
<span>{{ 'builder.columns' | translate }}</span>
<small class="field-desc">{{ 'builder.sectionColumnsDesc' | translate }}</small>
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
</label>
</div>
</div>
<div class="block-card__actions">
<label class="block-card__visible-toggle">
<app-toggle [ngModel]="section.visible !== false" (ngModelChange)="updateSection(section.id, 'visible', $event)" [ariaLabel]="'builder.visible' | translate" />
<span>{{ 'builder.visible' | translate }}</span>
<small class="field-desc">{{ 'builder.sectionVisibleDesc' | translate }}</small>
</label>
<label>
<span>{{ 'builder.layoutStrategyLabel' | translate }}</span>
<small class="field-desc">{{ 'builder.layoutStrategyDesc' | translate }}</small>
<select [ngModel]="section.layout?.strategy || 'stack'" (ngModelChange)="updateLayout(section.id, 'strategy', $event)">
@for (option of layoutStrategyOptions; track option.value) {
<option [value]="option.value">{{ option.labelKey | translate }}</option>
}
</select>
<small class="field-desc">{{ layoutStrategyDescKey(section.layout?.strategy || 'stack') | translate }}</small>
</label>
<label>
<span>{{ 'builder.columns' | translate }}</span>
<small class="field-desc">{{ 'builder.sectionColumnsDesc' | translate }}</small>
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
</label>
<button type="button" [attr.aria-label]="'builder.widgetDuplicate' | translate" (click)="duplicateBlock(section.id)">
<span class="pi pi-copy" aria-hidden="true"></span>
</button>
<button type="button" class="block-card__remove" [attr.aria-label]="'builder.widgetRemove' | translate" (click)="removeBlock(section.id)">
<span class="pi pi-trash" aria-hidden="true"></span>
</button>
</div>
</div>
}
</div>
<div class="block-catalog">
<p class="field-desc">{{ 'builder.addBlockHint' | translate }}</p>
<div class="block-catalog__grid">
@for (entry of blockCatalog; track entry.type) {
<button type="button" class="block-catalog__item" (click)="addBlock(entry.type)">
<span class="pi {{ entry.icon }}" aria-hidden="true"></span>
<span>{{ entry.labelKey | translate }}</span>
</button>
}
</div>
</div>
</app-section-card>
}

View File

@@ -0,0 +1,98 @@
.block-list { display: grid; gap: 10px; margin: 10px 0; }
.block-card {
display: grid;
grid-template-columns: auto auto 1fr auto;
gap: 12px;
align-items: start;
padding: 12px;
border: 1px solid var(--border-color, #d3dad9);
border-radius: var(--radius-sm);
background: #fff;
&.block-card--hidden { opacity: 0.6; }
&.cdk-drag-preview { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); }
&.cdk-drag-placeholder { opacity: 0.3; }
}
.block-card__handle {
cursor: grab;
color: var(--text-secondary, #6b7280);
padding-top: 4px;
}
.block-card__preview {
width: 44px;
height: 44px;
border-radius: var(--radius-sm);
background: var(--bg-secondary, #f7f8f8);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
color: var(--color-primary, #2f8f5b);
}
.block-card__title-row { display: flex; align-items: center; gap: 8px; }
.block-card__desc { margin: 2px 0 8px; color: var(--text-secondary, #6b7280); font-size: 0.85rem; }
.block-card__hidden-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--text-secondary, #6b7280);
border: 1px solid var(--border-color, #d3dad9);
border-radius: 999px;
padding: 1px 8px;
}
.block-card__actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
button {
border: none;
background: transparent;
cursor: pointer;
color: var(--text-secondary, #6b7280);
&:hover { color: var(--text-primary, #1e3c38); }
}
}
.block-card__visible-toggle { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; }
.block-card__remove:hover { color: var(--danger-color, #c0392b); }
.block-catalog { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color, #d3dad9); }
.block-catalog__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.block-catalog__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 8px;
border: 1px dashed var(--border-color, #d3dad9);
border-radius: var(--radius-sm);
background: var(--bg-secondary, #f7f8f8);
cursor: pointer;
font-size: 0.8rem;
color: var(--text-primary, #1e3c38);
.pi { font-size: 1.2rem; color: var(--color-primary, #2f8f5b); }
&:hover { border-color: var(--color-primary, #2f8f5b); background: #fff; }
&:focus-visible { outline: 2px solid var(--color-primary, #2f8f5b); outline-offset: 2px; }
}
@media (max-width: 640px) {
.block-card { grid-template-columns: auto 1fr; }
.block-card__actions { grid-column: 1 / -1; flex-direction: row; justify-content: flex-end; }
}

View File

@@ -5,9 +5,11 @@ 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 { ButtonComponent } from '../../../shared/ui/button/button.component';
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
import { HomepageOverviewComponent } from './homepage/homepage-overview.component';
import { SectionConfig } from '../../../shared/models/config';
export interface LayoutStrategyOption {
value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split';
@@ -15,12 +17,34 @@ export interface LayoutStrategyOption {
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: string;
labelKey: string;
descKey: string;
defaultLayout: LayoutStrategyOption['value'];
}
const BLOCK_CATALOG: BlockCatalogEntry[] = [
{ type: 'hero', icon: 'pi-image', labelKey: 'builder.blockHero', descKey: 'builder.blockHeroDesc', defaultLayout: 'hero' },
{ type: 'categories', icon: 'pi-th-large', labelKey: 'builder.blockCategories', descKey: 'builder.blockCategoriesDesc', defaultLayout: 'grid' },
{ type: 'product-collection', icon: 'pi-box', labelKey: 'builder.blockFeaturedProducts', descKey: 'builder.blockFeaturedProductsDesc', defaultLayout: 'grid' },
{ type: 'product-carousel', icon: 'pi-images', labelKey: 'builder.blockProductCarousel', descKey: 'builder.blockProductCarouselDesc', defaultLayout: 'carousel' },
{ type: 'recently-viewed', icon: 'pi-history', labelKey: 'builder.blockRecentlyViewed', descKey: 'builder.blockRecentlyViewedDesc', defaultLayout: 'grid' },
{ type: 'banner', icon: 'pi-megaphone', labelKey: 'builder.blockBanner', descKey: 'builder.blockBannerDesc', defaultLayout: 'split' },
{ type: 'partners', icon: 'pi-verified', labelKey: 'builder.blockPartners', descKey: 'builder.blockPartnersDesc', defaultLayout: 'stack' },
{ type: 'html', icon: 'pi-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, HomepageOverviewComponent],
imports: [DragDropModule, FormsModule, TranslatePipe, InputComponent, ButtonComponent, SectionCardComponent, ToggleComponent, HomepageOverviewComponent],
templateUrl: './homepage-section.component.html',
styleUrls: ['./section.shared.scss'],
styleUrls: ['./section.shared.scss', './homepage-section.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorHomepageSectionComponent {
@@ -33,6 +57,8 @@ export class ProjectEditorHomepageSectionComponent {
};
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' },
@@ -45,44 +71,96 @@ export class ProjectEditorHomepageSectionComponent {
return this.layoutStrategyOptions.find(option => option.value === strategy)?.descKey ?? '';
}
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): string {
return BLOCK_BY_TYPE.get(type)?.icon ?? 'pi-stop';
}
drop(event: CdkDragDrop<unknown[]>): 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 }))
}))
}));
this.replaceSections(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) })
}))
}))
}));
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]);
}
removeBlock(sectionId: string): void {
if (!confirm(this.translate.t('builder.blockRemoveConfirm'))) {
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: page.sections.map(section => section.id !== sectionId ? section : ({
...section,
layout: {
...section.layout,
[key]: key === 'columns' ? Number(value) || 1 : value,
}
}))
}))
pages: current.pages.map(page => page.id !== this.homePage()?.id ? page : ({ ...page, sections }))
}));
}
}

View File

@@ -35,10 +35,39 @@
@switch (entry.widget.type) {
@case ('hero') {
<div class="editor-grid two">
<label><span>{{ 'builder.layoutLabel' | translate }}</span><small class="field-desc">{{ 'builder.widgetLayoutDesc' | translate }}</small><app-input [ngModel]="entry.widget.props['layout'] || ''" (ngModelChange)="updateProp(entry.widget.id, 'layout', $event)" /></label>
<label><span>{{ 'builder.height' | translate }}</span><small class="field-desc">{{ 'builder.widgetHeightDesc' | translate }}</small><app-input [ngModel]="entry.widget.props['height'] || ''" (ngModelChange)="updateProp(entry.widget.id, 'height', $event)" /></label>
<label>
<span>{{ 'builder.layoutLabel' | translate }}</span>
<small class="field-desc">{{ 'builder.widgetLayoutDesc' | translate }}</small>
<select [ngModel]="entry.widget.props['layout'] || 'full-bleed'" (ngModelChange)="updateProp(entry.widget.id, 'layout', $event)">
<option value="full-bleed">{{ 'builder.heroLayoutFullBleed' | translate }}</option>
<option value="boxed">{{ 'builder.heroLayoutBoxed' | translate }}</option>
</select>
</label>
<label>
<span>{{ 'builder.height' | translate }}</span>
<small class="field-desc">{{ 'builder.widgetHeightDesc' | translate }}</small>
<select [ngModel]="entry.widget.props['height'] || '60vh'" (ngModelChange)="updateProp(entry.widget.id, 'height', $event)">
<option value="40vh">{{ 'builder.heroHeightCompact' | translate }}</option>
<option value="60vh">{{ 'builder.heroHeightMedium' | translate }}</option>
<option value="80vh">{{ 'builder.heroHeightTall' | translate }}</option>
<option value="100vh">{{ 'builder.heroHeightFullScreen' | translate }}</option>
</select>
</label>
<label class="toggle-row"><app-toggle [ngModel]="!!entry.widget.props['overlay']" (ngModelChange)="updateProp(entry.widget.id, 'overlay', $event)" [ariaLabel]="'builder.overlay' | translate" /><span>{{ 'builder.overlay' | translate }}</span><small class="field-desc">{{ 'builder.widgetOverlayDesc' | translate }}</small></label>
<label class="toggle-row"><app-toggle [ngModel]="!!entry.widget.props['autoplay']" (ngModelChange)="updateProp(entry.widget.id, 'autoplay', $event)" [ariaLabel]="'builder.autoplay' | translate" /><span>{{ 'builder.autoplay' | translate }}</span><small class="field-desc">{{ 'builder.widgetAutoplayDesc' | translate }}</small></label>
<div class="full">
<h4 class="widget-card__subheading">{{ 'builder.heroSlides' | translate }}</h4>
<p class="field-desc">{{ 'builder.heroSlidesHint' | translate }}</p>
@for (slide of heroSlides(entry.widget); track $index) {
<div class="hero-slide-row">
<app-input [ngModel]="slide.title" (ngModelChange)="updateHeroSlide(entry.widget.id, $index, 'title', $event)" [placeholder]="'builder.heroSlideTitlePlaceholder' | translate" />
<app-input [ngModel]="slide.subtitle" (ngModelChange)="updateHeroSlide(entry.widget.id, $index, 'subtitle', $event)" [placeholder]="'builder.heroSlideSubtitlePlaceholder' | translate" />
<button type="button" class="hero-slide-row__remove" (click)="removeHeroSlide(entry.widget.id, $index)" [attr.aria-label]="'builder.removeSlide' | translate">&times;</button>
</div>
}
<app-button variant="secondary" size="sm" (click)="addHeroSlide(entry.widget.id)">{{ 'builder.addSlide' | translate }}</app-button>
</div>
</div>
}
@case ('categories') {

View File

@@ -94,3 +94,24 @@
color: var(--text-light);
}
}
.widget-card__subheading { margin: 12px 0 4px; font-size: 0.9rem; }
.hero-slide-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
app-input { flex: 1; }
}
.hero-slide-row__remove {
border: none;
background: transparent;
cursor: pointer;
color: var(--text-secondary, #6b7280);
font-size: 1rem;
&:hover { color: var(--danger-color, #c0392b); }
}

View File

@@ -4,10 +4,16 @@ 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 { ButtonComponent } from '../../../shared/ui/button/button.component';
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
import { WidgetConfig } from '../../../shared/models/config';
interface HeroSlideDraft {
title: string;
subtitle: string;
}
const WIDGET_ICONS: Record<string, string> = {
hero: 'pi-image',
categories: 'pi-th-large',
@@ -23,7 +29,7 @@ const WIDGET_LABEL_KEYS: Record<string, string> = {
@Component({
selector: 'app-project-editor-widgets-section',
standalone: true,
imports: [FormsModule, TranslatePipe, InputComponent, SectionCardComponent, ToggleComponent],
imports: [FormsModule, TranslatePipe, InputComponent, ButtonComponent, SectionCardComponent, ToggleComponent],
templateUrl: './widgets-section.component.html',
styleUrls: ['./section.shared.scss', './widgets-section.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -136,6 +142,44 @@ export class ProjectEditorWidgetsSectionComponent {
this.updateWidget(widgetId, props => ({ ...props, [key]: value }));
}
/** Extra slides beyond the widget's primary title/subtitle (which stay editable as "slide 1" via the existing fields above this list). */
heroSlides(widget: WidgetConfig): HeroSlideDraft[] {
const raw = widget.props?.['slides'];
if (!Array.isArray(raw)) {
return [];
}
return raw.map(slide => ({
title: typeof slide?.title === 'string' ? slide.title : '',
subtitle: typeof slide?.subtitle === 'string' ? slide.subtitle : '',
}));
}
addHeroSlide(widgetId: string): void {
this.updateWidget(widgetId, props => ({
...props,
slides: [...this.slidesArray(props), { title: '', subtitle: '' }],
}));
}
updateHeroSlide(widgetId: string, index: number, field: 'title' | 'subtitle', value: string): void {
this.updateWidget(widgetId, props => ({
...props,
slides: this.slidesArray(props).map((slide, i) => i === index ? { ...slide, [field]: value } : slide),
}));
}
removeHeroSlide(widgetId: string, index: number): void {
this.updateWidget(widgetId, props => ({
...props,
slides: this.slidesArray(props).filter((_, i) => i !== index),
}));
}
private slidesArray(props: Record<string, unknown>): HeroSlideDraft[] {
const raw = props['slides'];
return Array.isArray(raw) ? raw : [];
}
private readonly jsonDrafts = signal<Record<string, string>>({});
private readonly jsonErrors = signal<Record<string, string>>({});