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,25 +4,37 @@
@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">
<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>
<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">{{ 'builder.layoutStrategyDesc' | translate }}</small>
<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>
<small class="field-desc">{{ layoutStrategyDescKey(section.layout?.strategy || 'stack') | translate }}</small>
</label>
<label>
<span>{{ 'builder.columns' | translate }}</span>
@@ -31,7 +43,33 @@
</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>
</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 : ({
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.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 : ({
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 }))
}));
}
}

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>>({});

View File

@@ -670,6 +670,18 @@ export const en: Translations = {
widgetHeightDesc: 'Preferred height for this widget (e.g. "480px" or "60vh").',
widgetOverlayDesc: 'Show a dark overlay behind hero text for readability.',
widgetAutoplayDesc: 'Automatically rotate hero slides without shopper interaction.',
heroLayoutFullBleed: 'Full width (edge to edge)',
heroLayoutBoxed: 'Boxed (within page margins)',
heroHeightCompact: 'Compact',
heroHeightMedium: 'Medium',
heroHeightTall: 'Tall',
heroHeightFullScreen: 'Full screen',
heroSlides: 'Slides',
heroSlidesHint: 'Add more slides to turn this into a slideshow. The fields above are slide 1. Autoplay and dots only appear once you have more than one slide.',
heroSlideTitlePlaceholder: 'Slide title',
heroSlideSubtitlePlaceholder: 'Slide subtitle',
addSlide: 'Add slide',
removeSlide: 'Remove slide',
widgetColumnsDesc: 'Number of category tiles per row.',
widgetCardsPerRowDesc: 'Number of product cards shown per row.',
widgetFiltersDesc: 'Show filter controls alongside the product collection.',
@@ -836,6 +848,26 @@ export const en: Translations = {
widgetRemove: 'Remove',
widgetRemoveConfirm: 'Remove this section from the homepage?',
widgetAdvancedSettings: 'Advanced settings',
homepageBlocksHint: 'These are the blocks customers see on your homepage, top to bottom. Drag the handle to reorder.',
addBlockHint: 'Add a block to your homepage:',
dragToReorder: 'Drag to reorder',
blockRemoveConfirm: 'Remove this block from the homepage?',
blockHero: 'Hero banner',
blockHeroDesc: 'The large welcome banner at the top of your homepage, with a title and call-to-action button.',
blockCategories: 'Categories',
blockCategoriesDesc: 'A grid of category tiles so customers can browse by department.',
blockFeaturedProducts: 'Featured products',
blockFeaturedProductsDesc: 'A grid of hand-picked or best-selling products.',
blockProductCarousel: 'Product carousel',
blockProductCarouselDesc: 'A horizontally scrolling row of products, with arrows to browse more.',
blockRecentlyViewed: 'Recently viewed',
blockRecentlyViewedDesc: 'Shows each customer the products they looked at recently.',
blockBanner: 'Promo banner',
blockBannerDesc: 'A single image with a headline and button, for sales or announcements.',
blockPartners: 'Partner logos',
blockPartnersDesc: 'A row of brand or partner logos for trust and credibility.',
blockCustomHtml: 'Custom block',
blockCustomHtmlDesc: 'Free-form HTML for anything the other blocks do not cover.',
},
dashboard: {
title: 'Dashboard',

View File

@@ -670,6 +670,18 @@ export const hy: Translations = {
widgetHeightDesc: 'Այս վիջեթի նախընտրելի բարձրությունը (օր․՝ "480px" կամ "60vh")։',
widgetOverlayDesc: 'Ցույց տալ մուգ overlay hero տեքստի հետևում՝ ընթեռնելիության համար։',
widgetAutoplayDesc: 'Ինքնաշխատ պտտել hero սլայդները առանց գնորդի մասնակցության։',
heroLayoutFullBleed: 'Ամբողջ լայնությամբ (եզրից եզր)',
heroLayoutBoxed: 'Շրջանակով (էջի լուսանցքների սահմաններում)',
heroHeightCompact: 'Կոմպակտ',
heroHeightMedium: 'Միջին',
heroHeightTall: 'Բարձր',
heroHeightFullScreen: 'Լիաէկրան',
heroSlides: 'Սլայդներ',
heroSlidesHint: 'Ավելացրեք սլայդներ՝ սլայդշոու ստանալու համար։ Վերևի դաշտերը 1-ին սլայդն են։ Ինքնաշարժ և կետերը հայտնվում են միայն մեկից ավելի սլայդի դեպքում։',
heroSlideTitlePlaceholder: 'Սլայդի վերնագիր',
heroSlideSubtitlePlaceholder: 'Սլայդի ենթավերնագիր',
addSlide: 'Ավելացնել սլայդ',
removeSlide: 'Հեռացնել սլայդը',
widgetColumnsDesc: 'Կատեգորիայի սալիկների քանակը մեկ շարքում։',
widgetCardsPerRowDesc: 'Ապրանքի քարտերի քանակը մեկ շարքում։',
widgetFiltersDesc: 'Ցույց տալ ֆիլտրերի կառավարման տարրերը ապրանքների հավաքածուի կողքին։',
@@ -836,6 +848,26 @@ export const hy: Translations = {
widgetRemove: 'Հեռացնել',
widgetRemoveConfirm: 'Հեռացնե՞լ այս բլոկը գլխավոր էջից։',
widgetAdvancedSettings: 'Լրացուցիչ կարգավորումներ',
homepageBlocksHint: 'Սրանք են բլոկները, որոնք գնորդները տեսնում են գլխավոր էջում վերևից ներքև։ Քաշեք բռնակից՝ կարգը փոխելու համար։',
addBlockHint: 'Ավելացնել բլոկ գլխավոր էջում.',
dragToReorder: 'Քաշեք՝ կարգը փոխելու համար',
blockRemoveConfirm: 'Հեռացնե՞լ այս բլոկը գլխավոր էջից։',
blockHero: 'Ողջույնի բաններ',
blockHeroDesc: 'Գլխավոր էջի վերևի մեծ բանները՝ վերնագրով և գործողության կոճակով։',
blockCategories: 'Կատեգորիաներ',
blockCategoriesDesc: 'Կատեգորիաների ցանց, որպեսզի գնորդները կարողանան դիտարկել բաժինները։',
blockFeaturedProducts: 'Առաջարկվող ապրանքներ',
blockFeaturedProductsDesc: 'Ընտրված կամ ամենավաճառվող ապրանքների ցանց։',
blockProductCarousel: 'Ապրանքների կարուսել',
blockProductCarouselDesc: 'Հորիզոնական ոլորվող ապրանքների շարք՝ սլաքներով ավելին դիտելու համար։',
blockRecentlyViewed: 'Վերջերս դիտվածներ',
blockRecentlyViewedDesc: 'Ցույց է տալիս յուրաքանչյուր գնորդի՝ նրա վերջերս դիտած ապրանքները։',
blockBanner: 'Գովազդային բաններ',
blockBannerDesc: 'Մեկ նկար՝ վերնագրով և կոճակով, զեղչերի կամ հայտարարությունների համար։',
blockPartners: 'Գործընկերների լոգոներ',
blockPartnersDesc: 'Բրենդի կամ գործընկերների լոգոների շարք՝ վստահության համար։',
blockCustomHtml: 'Սեփական բլոկ',
blockCustomHtmlDesc: 'Ազատ HTML այն ամենի համար, ինչ մյուս բլոկները չեն ծածկում։',
},
dashboard: {
title: 'Կառավարման վահանակ',

View File

@@ -670,6 +670,18 @@ export const ru: Translations = {
widgetHeightDesc: 'Предпочтительная высота виджета (например, "480px" или "60vh").',
widgetOverlayDesc: 'Показывать тёмную подложку под текстом hero для читаемости.',
widgetAutoplayDesc: 'Автоматически прокручивать слайды hero без участия покупателя.',
heroLayoutFullBleed: 'На всю ширину (край в край)',
heroLayoutBoxed: 'В рамке (с отступами страницы)',
heroHeightCompact: 'Компактная',
heroHeightMedium: 'Средняя',
heroHeightTall: 'Высокая',
heroHeightFullScreen: 'На весь экран',
heroSlides: 'Слайды',
heroSlidesHint: 'Добавьте ещё слайды, чтобы получился слайд-шоу. Поля выше — это слайд 1. Автопрокрутка и точки появляются только при более чем одном слайде.',
heroSlideTitlePlaceholder: 'Заголовок слайда',
heroSlideSubtitlePlaceholder: 'Подзаголовок слайда',
addSlide: 'Добавить слайд',
removeSlide: 'Удалить слайд',
widgetColumnsDesc: 'Количество плиток категорий в ряду.',
widgetCardsPerRowDesc: 'Количество карточек товаров в ряду.',
widgetFiltersDesc: 'Показывать элементы управления фильтрами рядом с подборкой товаров.',
@@ -836,6 +848,26 @@ export const ru: Translations = {
widgetRemove: 'Удалить',
widgetRemoveConfirm: 'Удалить этот блок с главной страницы?',
widgetAdvancedSettings: 'Дополнительные настройки',
homepageBlocksHint: 'Это блоки, которые покупатели видят на главной странице сверху вниз. Перетаскивайте за ручку, чтобы изменить порядок.',
addBlockHint: 'Добавить блок на главную страницу:',
dragToReorder: 'Перетащите, чтобы изменить порядок',
blockRemoveConfirm: 'Удалить этот блок с главной страницы?',
blockHero: 'Приветственный баннер',
blockHeroDesc: 'Крупный баннер вверху главной страницы с заголовком и кнопкой призыва к действию.',
blockCategories: 'Категории',
blockCategoriesDesc: 'Сетка плиток категорий, чтобы покупатели могли просматривать разделы.',
blockFeaturedProducts: 'Рекомендуемые товары',
blockFeaturedProductsDesc: 'Сетка отобранных вручную или самых продаваемых товаров.',
blockProductCarousel: 'Карусель товаров',
blockProductCarouselDesc: 'Горизонтально прокручиваемый ряд товаров со стрелками для просмотра других.',
blockRecentlyViewed: 'Недавно просмотренные',
blockRecentlyViewedDesc: 'Показывает каждому покупателю товары, которые он недавно смотрел.',
blockBanner: 'Промо-баннер',
blockBannerDesc: 'Одно изображение с заголовком и кнопкой — для акций или объявлений.',
blockPartners: 'Логотипы партнёров',
blockPartnersDesc: 'Ряд логотипов брендов или партнёров для доверия.',
blockCustomHtml: 'Свой блок',
blockCustomHtmlDesc: 'Произвольный HTML для всего, что не покрывают другие блоки.',
},
dashboard: {
title: 'Панель управления',

View File

@@ -668,6 +668,18 @@ export interface Translations {
widgetHeightDesc: string;
widgetOverlayDesc: string;
widgetAutoplayDesc: string;
heroLayoutFullBleed: string;
heroLayoutBoxed: string;
heroHeightCompact: string;
heroHeightMedium: string;
heroHeightTall: string;
heroHeightFullScreen: string;
heroSlides: string;
heroSlidesHint: string;
heroSlideTitlePlaceholder: string;
heroSlideSubtitlePlaceholder: string;
addSlide: string;
removeSlide: string;
widgetColumnsDesc: string;
widgetCardsPerRowDesc: string;
widgetFiltersDesc: string;
@@ -835,6 +847,26 @@ export interface Translations {
widgetRemove: string;
widgetRemoveConfirm: string;
widgetAdvancedSettings: string;
homepageBlocksHint: string;
addBlockHint: string;
dragToReorder: string;
blockRemoveConfirm: string;
blockHero: string;
blockHeroDesc: string;
blockCategories: string;
blockCategoriesDesc: string;
blockFeaturedProducts: string;
blockFeaturedProductsDesc: string;
blockProductCarousel: string;
blockProductCarouselDesc: string;
blockRecentlyViewed: string;
blockRecentlyViewedDesc: string;
blockBanner: string;
blockBannerDesc: string;
blockPartners: string;
blockPartnersDesc: string;
blockCustomHtml: string;
blockCustomHtmlDesc: string;
};
dashboard: {
title: string;

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,7 +15,31 @@ import { ProductCollectionWidgetData } from '../contracts/widget-data.contract';
}
@if (data?.products?.length) {
@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>
}
@@ -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);
}
}