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:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 }))
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">×</button>
|
||||
</div>
|
||||
}
|
||||
<app-button variant="secondary" size="sm" (click)="addHeroSlide(entry.widget.id)">{{ 'builder.addSlide' | translate }}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('categories') {
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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>>({});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Կառավարման վահանակ',
|
||||
|
||||
@@ -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: 'Панель управления',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
>←</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)"
|
||||
>→</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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user