-
-
{{ section.id }}
-
+
+
+
{{ 'builder.addBlockHint' | translate }}
+
+ @for (entry of blockCatalog; track entry.type) {
+
+ }
+
+
}
diff --git a/src/app/features/project-editor/sections/homepage-section.component.scss b/src/app/features/project-editor/sections/homepage-section.component.scss
new file mode 100644
index 0000000..8794cca
--- /dev/null
+++ b/src/app/features/project-editor/sections/homepage-section.component.scss
@@ -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; }
+}
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 85d253b..82f77dc 100644
--- a/src/app/features/project-editor/sections/homepage-section.component.ts
+++ b/src/app/features/project-editor/sections/homepage-section.component.ts
@@ -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
): 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 }))
}));
}
}
diff --git a/src/app/features/project-editor/sections/widgets-section.component.html b/src/app/features/project-editor/sections/widgets-section.component.html
index 2dfb1c4..841ac4e 100644
--- a/src/app/features/project-editor/sections/widgets-section.component.html
+++ b/src/app/features/project-editor/sections/widgets-section.component.html
@@ -35,10 +35,39 @@
@switch (entry.widget.type) {
@case ('hero') {
-
{{ 'builder.layoutLabel' | translate }}{{ 'builder.widgetLayoutDesc' | translate }}
-
{{ 'builder.height' | translate }}{{ 'builder.widgetHeightDesc' | translate }}
+
+ {{ 'builder.layoutLabel' | translate }}
+ {{ 'builder.widgetLayoutDesc' | translate }}
+
+
+
+ {{ 'builder.height' | translate }}
+ {{ 'builder.widgetHeightDesc' | translate }}
+
+
{{ 'builder.overlay' | translate }}{{ 'builder.widgetOverlayDesc' | translate }}
{{ 'builder.autoplay' | translate }}{{ 'builder.widgetAutoplayDesc' | translate }}
+
+
+
+
{{ 'builder.heroSlidesHint' | translate }}
+ @for (slide of heroSlides(entry.widget); track $index) {
+
+ }
+
{{ 'builder.addSlide' | translate }}
+
}
@case ('categories') {
diff --git a/src/app/features/project-editor/sections/widgets-section.component.scss b/src/app/features/project-editor/sections/widgets-section.component.scss
index 5d1a80f..234c55c 100644
--- a/src/app/features/project-editor/sections/widgets-section.component.scss
+++ b/src/app/features/project-editor/sections/widgets-section.component.scss
@@ -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); }
+}
diff --git a/src/app/features/project-editor/sections/widgets-section.component.ts b/src/app/features/project-editor/sections/widgets-section.component.ts
index 243e875..a047dad 100644
--- a/src/app/features/project-editor/sections/widgets-section.component.ts
+++ b/src/app/features/project-editor/sections/widgets-section.component.ts
@@ -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 = {
hero: 'pi-image',
categories: 'pi-th-large',
@@ -23,7 +29,7 @@ const WIDGET_LABEL_KEYS: Record = {
@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): HeroSlideDraft[] {
+ const raw = props['slides'];
+ return Array.isArray(raw) ? raw : [];
+ }
+
private readonly jsonDrafts = signal>({});
private readonly jsonErrors = signal>({});
diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts
index ba3d001..493453c 100644
--- a/src/app/i18n/en.ts
+++ b/src/app/i18n/en.ts
@@ -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',
diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts
index f2eeb06..6d1dd02 100644
--- a/src/app/i18n/hy.ts
+++ b/src/app/i18n/hy.ts
@@ -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: 'Կառավարման վահանակ',
diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts
index 2e70721..dff95a1 100644
--- a/src/app/i18n/ru.ts
+++ b/src/app/i18n/ru.ts
@@ -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: 'Панель управления',
diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts
index 585bbe6..5c463ed 100644
--- a/src/app/i18n/translations.ts
+++ b/src/app/i18n/translations.ts
@@ -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;
diff --git a/src/app/widgets/contracts/widget-data.contract.ts b/src/app/widgets/contracts/widget-data.contract.ts
index 7e88012..8949b72 100644
--- a/src/app/widgets/contracts/widget-data.contract.ts
+++ b/src/app/widgets/contracts/widget-data.contract.ts
@@ -7,10 +7,19 @@ export interface WidgetResolvedContext {
settings: Record;
}
+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 {
diff --git a/src/app/widgets/resolvers/data-source-resolver.service.ts b/src/app/widgets/resolvers/data-source-resolver.service.ts
index af29ad1..0d9bfc6 100644
--- a/src/app/widgets/resolvers/data-source-resolver.service.ts
+++ b/src/app/widgets/resolvers/data-source-resolver.service.ts
@@ -85,12 +85,26 @@ export class DataSourceResolverService {
}
private toHeroData(section: SectionConfig, settings: Record): HeroWidgetData {
+ const rawSlides = settings['slides'];
+ const slides = Array.isArray(rawSlides)
+ ? rawSlides
+ .filter((slide): slide is Record => !!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,
};
}
diff --git a/src/app/widgets/ui/hero-widget.component.ts b/src/app/widgets/ui/hero-widget.component.ts
index dbeb646..e555b22 100644
--- a/src/app/widgets/ui/hero-widget.component.ts
+++ b/src/app/widgets/ui/hero-widget.component.ts
@@ -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: `
@@ -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();
+ readonly activeIndex = signal(0);
+ private readonly dataSignal = signal(null);
+ private autoplayHandle: ReturnType | null = null;
+
+ readonly allSlides = computed(() => {
+ 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(() => 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;
+ }
+ }
}
diff --git a/src/app/widgets/ui/product-carousel-widget.component.ts b/src/app/widgets/ui/product-carousel-widget.component.ts
index e7aa842..5054a80 100644
--- a/src/app/widgets/ui/product-carousel-widget.component.ts
+++ b/src/app/widgets/ui/product-carousel-widget.component.ts
@@ -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) {
-
+ @if (isCarousel) {
+
+ } @else {
+
+ }
} @else if (data?.emptyMessage) {
{{ data?.emptyMessage }}
- }
+ }
`,
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();
+
+ @ViewChild('scroller') private scroller?: ElementRef;
+
+ 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);
+ }
}