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

@@ -7,10 +7,19 @@ export interface WidgetResolvedContext {
settings: Record<string, unknown>;
}
export interface HeroSlideData {
title: string;
subtitle?: string;
ctaLabel?: string;
}
export interface HeroWidgetData extends WidgetResolvedContext {
title: string;
subtitle?: string;
ctaLabel?: string;
/** Extra slides beyond the primary title/subtitle/ctaLabel above, for a real multi-slide hero. Empty when the admin has not added any. */
slides?: HeroSlideData[];
autoplay?: boolean;
}
export interface CategoriesWidgetData extends WidgetResolvedContext {

View File

@@ -85,12 +85,26 @@ export class DataSourceResolverService {
}
private toHeroData(section: SectionConfig, settings: Record<string, unknown>): HeroWidgetData {
const rawSlides = settings['slides'];
const slides = Array.isArray(rawSlides)
? rawSlides
.filter((slide): slide is Record<string, unknown> => !!slide && typeof slide === 'object')
.map(slide => ({
title: String(slide['title'] ?? ''),
subtitle: slide['subtitle'] != null ? String(slide['subtitle']) : undefined,
ctaLabel: slide['ctaLabel'] != null ? String(slide['ctaLabel']) : undefined,
}))
.filter(slide => slide.title)
: undefined;
return {
section,
settings,
title: String(settings['title'] ?? ''),
subtitle: settings['subtitle'] != null ? String(settings['subtitle']) : undefined,
ctaLabel: settings['ctaLabel'] != null ? String(settings['ctaLabel']) : undefined
ctaLabel: settings['ctaLabel'] != null ? String(settings['ctaLabel']) : undefined,
slides,
autoplay: settings['autoplay'] === true,
};
}

View File

@@ -1,7 +1,9 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, computed, signal } from '@angular/core';
import { SectionConfig } from '../../shared/models/config';
import { HeroWidgetData } from '../contracts/widget-data.contract';
import { HeroSlideData, HeroWidgetData } from '../contracts/widget-data.contract';
const AUTOPLAY_INTERVAL_MS = 5000;
@Component({
selector: 'app-hero-widget',
@@ -9,13 +11,29 @@ import { HeroWidgetData } from '../contracts/widget-data.contract';
imports: [CommonModule],
template: `
<section class="hero-widget">
@if (data; as widgetData) {
<h1 class="hero-widget__title">{{ widgetData.title }}</h1>
@if (widgetData.subtitle) {
<p class="hero-widget__subtitle">{{ widgetData.subtitle }}</p>
@if (activeSlide(); as slide) {
<h1 class="hero-widget__title">{{ slide.title }}</h1>
@if (slide.subtitle) {
<p class="hero-widget__subtitle">{{ slide.subtitle }}</p>
}
@if (widgetData.ctaLabel) {
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ widgetData.ctaLabel }}</button>
@if (slide.ctaLabel) {
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button>
}
@if (allSlides().length > 1) {
<div class="hero-widget__dots" role="tablist" aria-label="Slides">
@for (dot of allSlides(); track $index) {
<button
type="button"
class="hero-widget__dot"
[class.hero-widget__dot--active]="$index === activeIndex()"
role="tab"
[attr.aria-selected]="$index === activeIndex()"
[attr.aria-label]="'Slide ' + ($index + 1)"
(click)="goTo($index)"
></button>
}
</div>
}
}
</section>
@@ -23,6 +41,7 @@ import { HeroWidgetData } from '../contracts/widget-data.contract';
styles: [
`
.hero-widget {
position: relative;
padding: var(--space-xl, 32px);
border-radius: var(--radius-lg, 16px);
background: linear-gradient(135deg, var(--bg-secondary, #f5f5f5) 0%, var(--bg-tertiary, #eef1f0) 100%);
@@ -71,6 +90,25 @@ import { HeroWidgetData } from '../contracts/widget-data.contract';
}
}
.hero-widget__dots {
display: flex;
gap: 8px;
margin-top: var(--space-lg, 24px);
}
.hero-widget__dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
padding: 0;
&--active { background: var(--primary-color, #497671); }
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
}
@keyframes hero-widget-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
@@ -92,13 +130,63 @@ import { HeroWidgetData } from '../contracts/widget-data.contract';
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeroWidgetComponent {
export class HeroWidgetComponent implements OnChanges, OnDestroy {
@Input() section: SectionConfig | null = null;
@Input() data: HeroWidgetData | null = null;
@Output() ctaClicked = new EventEmitter<void>();
readonly activeIndex = signal(0);
private readonly dataSignal = signal<HeroWidgetData | null>(null);
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
readonly allSlides = computed<HeroSlideData[]>(() => {
const current = this.dataSignal();
if (!current) {
return [];
}
const primary: HeroSlideData = { title: current.title, subtitle: current.subtitle, ctaLabel: current.ctaLabel };
return [primary, ...(current.slides ?? [])];
});
readonly activeSlide = computed<HeroSlideData | null>(() => this.allSlides()[this.activeIndex()] ?? null);
ngOnChanges(changes: SimpleChanges): void {
if (changes['data']) {
this.dataSignal.set(this.data);
this.activeIndex.set(0);
this.setupAutoplay();
}
}
ngOnDestroy(): void {
this.clearAutoplay();
}
goTo(index: number): void {
this.activeIndex.set(index);
this.setupAutoplay();
}
onCtaClick(): void {
this.ctaClicked.emit();
}
private setupAutoplay(): void {
this.clearAutoplay();
const slides = this.allSlides();
if (!this.data?.autoplay || slides.length <= 1) {
return;
}
this.autoplayHandle = setInterval(() => {
this.activeIndex.update(index => (index + 1) % slides.length);
}, AUTOPLAY_INTERVAL_MS);
}
private clearAutoplay(): void {
if (this.autoplayHandle !== null) {
clearInterval(this.autoplayHandle);
this.autoplayHandle = null;
}
}
}

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, ViewChild, signal } from '@angular/core';
import { SectionConfig } from '../../shared/models/config';
import { CatalogProductGridComponent } from '../../features/website/catalog/components/product-grid/product-grid.component';
import { ProductCollectionWidgetData } from '../contracts/widget-data.contract';
@@ -15,10 +15,34 @@ import { ProductCollectionWidgetData } from '../contracts/widget-data.contract';
}
@if (data?.products?.length) {
<app-catalog-product-grid [products]="data?.products ?? []" />
@if (isCarousel) {
<div class="product-carousel-widget__track">
<button
type="button"
class="product-carousel-widget__arrow product-carousel-widget__arrow--prev"
aria-label="Previous products"
[disabled]="atStart()"
(click)="scrollBy(-1)"
>&larr;</button>
<div class="product-carousel-widget__scroller" #scroller (scroll)="onScroll()">
<app-catalog-product-grid [products]="data?.products ?? []" layout="compact" />
</div>
<button
type="button"
class="product-carousel-widget__arrow product-carousel-widget__arrow--next"
aria-label="Next products"
[disabled]="atEnd()"
(click)="scrollBy(1)"
>&rarr;</button>
</div>
} @else {
<app-catalog-product-grid [products]="data?.products ?? []" />
}
} @else if (data?.emptyMessage) {
<p class="product-carousel-widget__empty">{{ data?.emptyMessage }}</p>
}
}
</section>
`,
styles: [
@@ -33,6 +57,48 @@ import { ProductCollectionWidgetData } from '../contracts/widget-data.contract';
}
.product-carousel-widget__empty { margin: 0; color: var(--text-secondary, #667a77); }
.product-carousel-widget__track {
display: flex;
align-items: center;
gap: 8px;
}
.product-carousel-widget__scroller {
flex: 1;
overflow-x: auto;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
::ng-deep .catalog-product-grid {
display: flex;
flex-wrap: nowrap;
gap: var(--space-md, 16px);
}
::ng-deep .catalog-product-shell {
flex: 0 0 auto;
width: 220px;
}
}
.product-carousel-widget__arrow {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--border-color, #d3dad9);
background: #fff;
color: var(--text-primary, #1e3c38);
cursor: pointer;
font-size: 1rem;
&:hover:not(:disabled) { background: var(--primary-color, #497671); color: #fff; }
&:disabled { opacity: 0.35; cursor: default; }
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -42,4 +108,30 @@ export class ProductCarouselWidgetComponent {
@Input() data: ProductCollectionWidgetData | null = null;
@Output() productSelected = new EventEmitter<unknown>();
@ViewChild('scroller') private scroller?: ElementRef<HTMLDivElement>;
readonly atStart = signal(true);
readonly atEnd = signal(false);
get isCarousel(): boolean {
return this.section?.layout?.strategy === 'carousel';
}
scrollBy(direction: -1 | 1): void {
const el = this.scroller?.nativeElement;
if (!el) {
return;
}
el.scrollBy({ left: direction * el.clientWidth * 0.8, behavior: 'smooth' });
}
onScroll(): void {
const el = this.scroller?.nativeElement;
if (!el) {
return;
}
this.atStart.set(el.scrollLeft <= 4);
this.atEnd.set(el.scrollLeft + el.clientWidth >= el.scrollWidth - 4);
}
}