diff --git a/src/app/features/project-editor/sections/homepage-section.component.html b/src/app/features/project-editor/sections/homepage-section.component.html index a696354..272ea6b 100644 --- a/src/app/features/project-editor/sections/homepage-section.component.html +++ b/src/app/features/project-editor/sections/homepage-section.component.html @@ -4,34 +4,72 @@ @if (fieldError('pages'); as msg) {

{{ msg }}

} -
+

{{ 'builder.homepageBlocksHint' | translate }}

+ +
@for (section of sections(); track section.id) { -
-
- {{ 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.heroSlides' | 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: `
- @if (data; as widgetData) { -

{{ widgetData.title }}

- @if (widgetData.subtitle) { -

{{ widgetData.subtitle }}

+ @if (activeSlide(); as slide) { +

{{ slide.title }}

+ @if (slide.subtitle) { +

{{ slide.subtitle }}

} - @if (widgetData.ctaLabel) { - + @if (slide.ctaLabel) { + + } + + @if (allSlides().length > 1) { +
+ @for (dot of allSlides(); track $index) { + + } +
} }
@@ -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) { - } + } `, 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); + } }