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,34 +4,72 @@
|
||||
@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">
|
||||
<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">{{ 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>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ 'builder.columns' | translate }}</span>
|
||||
<small class="field-desc">{{ 'builder.sectionColumnsDesc' | translate }}</small>
|
||||
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
|
||||
</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>
|
||||
<small class="field-desc">{{ 'builder.sectionVisibleDesc' | translate }}</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ 'builder.layoutStrategyLabel' | translate }}</span>
|
||||
<small class="field-desc">{{ 'builder.layoutStrategyDesc' | 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>
|
||||
<small class="field-desc">{{ 'builder.sectionColumnsDesc' | translate }}</small>
|
||||
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
|
||||
</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 : ({
|
||||
...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 }))
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>({});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user