fix: manifest-aware layout picker, real carousel items-per-page, hero arrows/swipe/2-panel
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Sprint E: homepage section editor now filters the layout-strategy picker to each widget's widget-manifest.json supportedLayouts instead of always showing all 5 strategies. columns field gated to widgets that read it (hero, product-collection carousel). Sprint F: closes client bug report (no items-per-page control, hero carousel not manually/automatically scrollable, no 1-2 slide big-carousel option). Product carousel item width now driven by layout.columns (reused, was already editable but dead). Hero widget gains prev/next arrows, touch swipe, and 1-2 panel mode via the same field. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,31 @@ Supersedes `docs/COMING-SOON-AUDIT.md` §5 sprint breakdown. One consolidated tr
|
|||||||
|
|
||||||
**What shipped:** Both Help and Documentation wired to real external links, not just Help. `comingSoon: true` remains in `admin-nav.model.ts` source as a fallback flag only — it is overridden to `false` at render time whenever bootstrap actually has the data, which it does today.
|
**What shipped:** Both Help and Documentation wired to real external links, not just Help. `comingSoon: true` remains in `admin-nav.model.ts` source as a fallback flag only — it is overridden to `false` at render time whenever bootstrap actually has the data, which it does today.
|
||||||
|
|
||||||
|
## Sprint E — Widget layout config correctness (manifest-aware editor)
|
||||||
|
|
||||||
|
Root cause confirmed 2026-08-05: `widget-manifest.json` already declares `supportedLayouts` per widget type (`hero`→`[hero, split]`, `categories`→`[grid]`, `product-collection`→`[carousel, grid]`), but `homepage-section.component.ts`'s `layoutStrategyPickerOptions` is a static 5-option list (`stack/grid/hero/carousel/split`) shown identically for every homepage section regardless of which widget backs it — it never reads the manifest. The `columns` field (`homepage-section.component.html:49`) is shown for every section too, but **no widget component reads `layout.columns`** — it is currently dead everywhere.
|
||||||
|
|
||||||
|
- [x] `homepage-section.component.ts`: resolve each section's widget type (via its bound widget id → `widget-registry`/manifest lookup) and filter `layoutStrategyPickerOptions` down to that widget's `supportedLayouts` before rendering the picker
|
||||||
|
- [x] Hide/disable the `columns` field for any section whose resolved widget doesn't consume it (only `product-collection` and, after Sprint F, `hero` will)
|
||||||
|
- [x] No behavior change for widgets that already worked (categories/recently-viewed/footer-nav keep their single valid layout, picker just stops offering the other 4 nonsensically)
|
||||||
|
|
||||||
|
**What shipped:** `homepage-section.component.ts` now injects `WidgetManifestService`, resolves each section's manifest entry directly by `section.type` (confirmed identical to the manifest `type` key — no separate widget-id lookup needed), and derives `layoutOptionsFor(section)` by filtering the static option list down to that entry's `supportedLayouts`. A stale/unsupported saved `strategy` value is appended back into the options list rather than dropped, so `app-visual-layout-picker` never renders with no active card. `showColumnsFor(section)` gates the `columns` field to the two componentKeys that actually read it (`hero` always, `product-collection` only in `carousel` strategy — grid mode ignores it). One correction to the plan's assumption: `recently-viewed`'s actual manifest entry declares `supportedLayouts: ["stack", "grid", "carousel"]` (3 options, not 1) — the picker now correctly reflects that per the manifest rather than the plan's guess.
|
||||||
|
|
||||||
|
## Sprint F — Carousel items-per-page (closes the client bug report)
|
||||||
|
|
||||||
|
Confirmed real, reported by a client, not fixed anywhere: neither carousel widget has an "items/slides per page" concept. Design: reuse the existing (currently dead) `layout.columns` field rather than inventing a new one — it is already editable in the Homepage section editor once Sprint E gates it to the right widgets.
|
||||||
|
|
||||||
|
- [x] `ProductCarouselWidgetComponent`: read `section.layout.columns` (default 4, min 1) to size `.catalog-product-shell` width as a fraction of the scroller instead of the hardcoded `220px` — gives real "items per page" control, arrows/scroll logic unchanged (already works)
|
||||||
|
- [x] `HeroWidgetComponent`: add manual prev/next arrows (parity with the product carousel's arrow buttons) in addition to the existing dots — closes "not scrollable manually"
|
||||||
|
- [x] `HeroWidgetComponent`: add swipe/drag (pointer events) support for touch — closes "not scrollable manually" on mobile
|
||||||
|
- [x] `HeroWidgetComponent`: support `layout.columns` = 1 or 2 to show one or two slide panels at once ("big carousel one or two slides per page") — 2-panel mode shows the active slide plus the next one side by side
|
||||||
|
- [x] Verify autoplay (`props.autoplay`, already exists, editor toggle already exists per `widgets-section.component.html:61`) still functions correctly alongside the new manual controls (manual interaction should not fight the autoplay timer — reset/pause timer on manual nav, matching common carousel UX)
|
||||||
|
- [x] i18n: any new aria-labels for the new hero arrows (reuse `common.previousProducts`/`common.nextProducts` keys if wording fits, or add `common.previousSlide`/`common.nextSlide`)
|
||||||
|
|
||||||
|
**What shipped:** `ProductCarouselWidgetComponent` sets `--items-per-page` as a CSS custom property (`[style.--items-per-page]`) driven by `itemsPerPage()` (default 4, min 1, floored), and `.catalog-product-shell` width is now `calc((100% - (var(--items-per-page, 4) - 1) * var(--space-md, 16px)) / var(--items-per-page, 4))` instead of a fixed `220px`. `HeroWidgetComponent` gained prev/next arrow buttons (same circular/bordered visual language as the product carousel's arrows), touch-event swipe (same threshold-based approach as `cart.component.ts`'s `onSwipeStart`, 50px threshold, left swipe = next, right swipe = prev), and 2-panel support via `layout.columns` (defaults to 1; `columns === 2` shows the active slide plus the next one side by side, falling back to 1 panel when there's only one slide total). All manual navigation (arrows, swipe, dots) routes through the existing `goTo()`, which already clears+restarts the autoplay timer, so no duplicate timer logic was needed. New i18n keys `common.previousSlide` / `common.nextSlide` added to `translations.ts`, `en.ts`, `ru.ts`, `hy.ts`.
|
||||||
|
|
||||||
|
Verification: `npx tsc --noEmit` and `npx ng build --configuration=development` both clean. Visually verified in the browser preview (`ng serve` on port 4200) by temporarily patching the embedded home-page sections in `src/assets/mock/bootstrap/bootstrap.json` (the actual runtime source for `/` — `src/assets/mock/bootstrap/homepage.json` is a separate, unused-by-this-route file) to `columns: 2` + a second slide for hero and `columns: 3` for the product carousel, confirming via DOM/computed-style inspection: hero rendered 2 slide panels with 2 working arrows, arrow clicks and simulated touch swipe both advanced/reversed the active dot correctly, and the carousel's `--items-per-page` CSS var read `3` with each `.catalog-product-shell` measuring ~348px (vs. the fixed 1110px/220px before). All temporary mock-data edits were reverted afterward (`git checkout`) — `bootstrap.json` and `homepage.json` are unchanged in the final diff. The Sprint E manifest-aware picker itself could only be verified by code inspection, not live in the browser — `/edit/:section` requires Telegram admin login, which cannot be completed in this environment.
|
||||||
|
|
||||||
## Housekeeping
|
## Housekeeping
|
||||||
|
|
||||||
- [x] Delete `docs/COMING-SOON-AUDIT.md`
|
- [x] Delete `docs/COMING-SOON-AUDIT.md`
|
||||||
|
|||||||
@@ -38,12 +38,13 @@
|
|||||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||||
>
|
>
|
||||||
<app-visual-layout-picker
|
<app-visual-layout-picker
|
||||||
[options]="layoutStrategyPickerOptions"
|
[options]="layoutOptionsFor(section)"
|
||||||
[ariaLabel]="'builder.layoutStrategyLabel' | translate"
|
[ariaLabel]="'builder.layoutStrategyLabel' | translate"
|
||||||
[ngModel]="section.layout?.strategy || 'stack'"
|
[ngModel]="section.layout?.strategy || 'stack'"
|
||||||
(ngModelChange)="updateLayout(section.id, 'strategy', $event)"
|
(ngModelChange)="updateLayout(section.id, 'strategy', $event)"
|
||||||
/>
|
/>
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
|
@if (showColumnsFor(section)) {
|
||||||
<app-form-field
|
<app-form-field
|
||||||
appHighlightSource="spacing"
|
appHighlightSource="spacing"
|
||||||
[label]="'builder.columns' | translate"
|
[label]="'builder.columns' | translate"
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
>
|
>
|
||||||
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
|
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
|
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
@@ -16,6 +17,8 @@ import { ConfirmDialogComponent } from '../../../shared/ui/confirm-dialog/confir
|
|||||||
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
||||||
import { VisualLayoutPickerComponent, VisualLayoutOption } from '../../../shared/ui/visual-layout-picker/visual-layout-picker.component';
|
import { VisualLayoutPickerComponent, VisualLayoutOption } from '../../../shared/ui/visual-layout-picker/visual-layout-picker.component';
|
||||||
import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive';
|
import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive';
|
||||||
|
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
|
||||||
|
import { WidgetLayoutSupport, WidgetManifestEntry } from '../../../widgets/contracts/widget-manifest.contract';
|
||||||
|
|
||||||
export interface LayoutStrategyOption {
|
export interface LayoutStrategyOption {
|
||||||
value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split';
|
value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split';
|
||||||
@@ -45,6 +48,9 @@ const BLOCK_CATALOG: BlockCatalogEntry[] = [
|
|||||||
|
|
||||||
const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
|
const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
|
||||||
|
|
||||||
|
/** Widget componentKeys (widget-manifest.json) that read `section.layout.columns`. Keep in sync with the widget components that actually consume it - see hero-widget.component.ts and product-carousel-widget.component.ts. */
|
||||||
|
const COMPONENT_KEYS_READING_COLUMNS = new Set(['hero', 'product-collection']);
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-project-editor-homepage-section',
|
selector: 'app-project-editor-homepage-section',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -56,7 +62,11 @@ const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
|
|||||||
export class ProjectEditorHomepageSectionComponent {
|
export class ProjectEditorHomepageSectionComponent {
|
||||||
private readonly facade = inject(ProjectEditorFacade);
|
private readonly facade = inject(ProjectEditorFacade);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
|
private readonly widgetManifest = inject(WidgetManifestService);
|
||||||
readonly homePage = this.facade.homepagePage;
|
readonly homePage = this.facade.homepagePage;
|
||||||
|
|
||||||
|
private readonly manifestEntries = toSignal(this.widgetManifest.getWidgets(), { initialValue: [] as WidgetManifestEntry[] });
|
||||||
|
private readonly manifestByType = computed(() => new Map(this.manifestEntries().map(entry => [entry.type, entry])));
|
||||||
readonly fieldError = (key: string): string | null => {
|
readonly fieldError = (key: string): string | null => {
|
||||||
const messageKey = this.facade.fieldError(key);
|
const messageKey = this.facade.fieldError(key);
|
||||||
return messageKey ? this.translate.t(messageKey) : null;
|
return messageKey ? this.translate.t(messageKey) : null;
|
||||||
@@ -89,6 +99,35 @@ export class ProjectEditorHomepageSectionComponent {
|
|||||||
icon: this.layoutStrategyIcons[option.value],
|
icon: this.layoutStrategyIcons[option.value],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** Layout strategy options filtered to what the section's bound widget actually supports (widget-manifest.json `supportedLayouts`). Keeps a stale/unsupported saved value selectable instead of rendering a picker with no active card. */
|
||||||
|
layoutOptionsFor(section: SectionConfig): VisualLayoutOption[] {
|
||||||
|
const supported = this.manifestByType().get(section.type)?.supportedLayouts;
|
||||||
|
if (!supported || supported.length === 0) {
|
||||||
|
return this.layoutStrategyPickerOptions;
|
||||||
|
}
|
||||||
|
const options = this.layoutStrategyPickerOptions.filter(option => supported.includes(option.value as WidgetLayoutSupport));
|
||||||
|
const current = section.layout?.strategy;
|
||||||
|
if (current && !options.some(option => option.value === current)) {
|
||||||
|
const currentOption = this.layoutStrategyPickerOptions.find(option => option.value === current);
|
||||||
|
if (currentOption) {
|
||||||
|
return [...options, currentOption];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the section's bound widget consumes `layout.columns` (see COMPONENT_KEYS_READING_COLUMNS). Product carousel only uses it in carousel strategy - grid mode ignores it. */
|
||||||
|
showColumnsFor(section: SectionConfig): boolean {
|
||||||
|
const componentKey = this.manifestByType().get(section.type)?.componentKey;
|
||||||
|
if (!componentKey || !COMPONENT_KEYS_READING_COLUMNS.has(componentKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (componentKey === 'product-collection') {
|
||||||
|
return (section.layout?.strategy ?? 'stack') === 'carousel';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
blockLabel(type: string): string {
|
blockLabel(type: string): string {
|
||||||
const entry = BLOCK_BY_TYPE.get(type);
|
const entry = BLOCK_BY_TYPE.get(type);
|
||||||
return entry ? this.translate.t(entry.labelKey) : type;
|
return entry ? this.translate.t(entry.labelKey) : type;
|
||||||
|
|||||||
@@ -1116,6 +1116,8 @@ export const en: Translations = {
|
|||||||
slidesLabel: 'Slides',
|
slidesLabel: 'Slides',
|
||||||
previousProducts: 'Previous products',
|
previousProducts: 'Previous products',
|
||||||
nextProducts: 'Next products',
|
nextProducts: 'Next products',
|
||||||
|
previousSlide: 'Previous slide',
|
||||||
|
nextSlide: 'Next slide',
|
||||||
closeDialog: 'Close dialog',
|
closeDialog: 'Close dialog',
|
||||||
dismiss: 'Dismiss',
|
dismiss: 'Dismiss',
|
||||||
qrCode: 'QR Code',
|
qrCode: 'QR Code',
|
||||||
|
|||||||
@@ -1116,6 +1116,8 @@ export const hy: Translations = {
|
|||||||
slidesLabel: 'Սլայդներ',
|
slidesLabel: 'Սլայդներ',
|
||||||
previousProducts: 'Նախորդ ապրանքները',
|
previousProducts: 'Նախորդ ապրանքները',
|
||||||
nextProducts: 'Հաջորդ ապրանքները',
|
nextProducts: 'Հաջորդ ապրանքները',
|
||||||
|
previousSlide: 'Նախորդ սլայդը',
|
||||||
|
nextSlide: 'Հաջորդ սլայդը',
|
||||||
closeDialog: 'Փակել պատուհանը',
|
closeDialog: 'Փակել պատուհանը',
|
||||||
dismiss: 'Փակել',
|
dismiss: 'Փակել',
|
||||||
qrCode: 'QR կոդ',
|
qrCode: 'QR կոդ',
|
||||||
|
|||||||
@@ -1116,6 +1116,8 @@ export const ru: Translations = {
|
|||||||
slidesLabel: 'Слайды',
|
slidesLabel: 'Слайды',
|
||||||
previousProducts: 'Предыдущие товары',
|
previousProducts: 'Предыдущие товары',
|
||||||
nextProducts: 'Следующие товары',
|
nextProducts: 'Следующие товары',
|
||||||
|
previousSlide: 'Предыдущий слайд',
|
||||||
|
nextSlide: 'Следующий слайд',
|
||||||
closeDialog: 'Закрыть диалог',
|
closeDialog: 'Закрыть диалог',
|
||||||
dismiss: 'Скрыть',
|
dismiss: 'Скрыть',
|
||||||
qrCode: 'QR-код',
|
qrCode: 'QR-код',
|
||||||
|
|||||||
@@ -1115,6 +1115,8 @@ export interface Translations {
|
|||||||
slidesLabel: string;
|
slidesLabel: string;
|
||||||
previousProducts: string;
|
previousProducts: string;
|
||||||
nextProducts: string;
|
nextProducts: string;
|
||||||
|
previousSlide: string;
|
||||||
|
nextSlide: string;
|
||||||
closeDialog: string;
|
closeDialog: string;
|
||||||
dismiss: string;
|
dismiss: string;
|
||||||
qrCode: string;
|
qrCode: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { HeroSlideData, HeroWidgetData } from '../contracts/widget-data.contract
|
|||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
|
|
||||||
const AUTOPLAY_INTERVAL_MS = 5000;
|
const AUTOPLAY_INTERVAL_MS = 5000;
|
||||||
|
const SWIPE_THRESHOLD_PX = 50;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-hero-widget',
|
selector: 'app-hero-widget',
|
||||||
@@ -12,14 +13,40 @@ const AUTOPLAY_INTERVAL_MS = 5000;
|
|||||||
imports: [CommonModule, TranslatePipe],
|
imports: [CommonModule, TranslatePipe],
|
||||||
template: `
|
template: `
|
||||||
<section class="hero-widget">
|
<section class="hero-widget">
|
||||||
@if (activeSlide(); as slide) {
|
@if (visibleSlides().length) {
|
||||||
<h1 class="hero-widget__title">{{ slide.title }}</h1>
|
<div class="hero-widget__nav">
|
||||||
|
@if (allSlides().length > 1) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hero-widget__arrow hero-widget__arrow--prev"
|
||||||
|
[attr.aria-label]="'common.previousSlide' | translate"
|
||||||
|
(click)="prevSlide()"
|
||||||
|
>←</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="hero-widget__slides" (touchstart)="onSwipeStart($event)" (touchend)="onSwipeEnd($event)">
|
||||||
|
@for (slide of visibleSlides(); track $index) {
|
||||||
|
<article class="hero-widget__slide">
|
||||||
|
<h2 class="hero-widget__title">{{ slide.title }}</h2>
|
||||||
@if (slide.subtitle) {
|
@if (slide.subtitle) {
|
||||||
<p class="hero-widget__subtitle">{{ slide.subtitle }}</p>
|
<p class="hero-widget__subtitle">{{ slide.subtitle }}</p>
|
||||||
}
|
}
|
||||||
@if (slide.ctaLabel) {
|
@if (slide.ctaLabel) {
|
||||||
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button>
|
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button>
|
||||||
}
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (allSlides().length > 1) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hero-widget__arrow hero-widget__arrow--next"
|
||||||
|
[attr.aria-label]="'common.nextSlide' | translate"
|
||||||
|
(click)="nextSlide()"
|
||||||
|
>→</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (allSlides().length > 1) {
|
@if (allSlides().length > 1) {
|
||||||
<div class="hero-widget__dots" role="tablist" [attr.aria-label]="'common.slidesLabel' | translate">
|
<div class="hero-widget__dots" role="tablist" [attr.aria-label]="'common.slidesLabel' | translate">
|
||||||
@@ -49,6 +76,40 @@ const AUTOPLAY_INTERVAL_MS = 5000;
|
|||||||
animation: hero-widget-in 420ms ease-out both;
|
animation: hero-widget-in 420ms ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-widget__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--space-sm, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-widget__slides {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-lg, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-widget__slide {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-widget__arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
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 { background: var(--primary-color, #497671); color: #fff; }
|
||||||
|
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
|
||||||
|
}
|
||||||
|
|
||||||
.hero-widget__title {
|
.hero-widget__title {
|
||||||
margin: 0 0 var(--space-sm, 8px);
|
margin: 0 0 var(--space-sm, 8px);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
@@ -140,6 +201,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
readonly activeIndex = signal(0);
|
readonly activeIndex = signal(0);
|
||||||
private readonly dataSignal = signal<HeroWidgetData | null>(null);
|
private readonly dataSignal = signal<HeroWidgetData | null>(null);
|
||||||
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
|
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private swipeStartX: number | null = null;
|
||||||
|
|
||||||
readonly allSlides = computed<HeroSlideData[]>(() => {
|
readonly allSlides = computed<HeroSlideData[]>(() => {
|
||||||
const current = this.dataSignal();
|
const current = this.dataSignal();
|
||||||
@@ -152,6 +214,25 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
readonly activeSlide = computed<HeroSlideData | null>(() => this.allSlides()[this.activeIndex()] ?? null);
|
readonly activeSlide = computed<HeroSlideData | null>(() => this.allSlides()[this.activeIndex()] ?? null);
|
||||||
|
|
||||||
|
/** 1 or 2, reusing `layout.columns` (no dedicated "slides per page" field). 2 has nothing to show a second panel with when there's only one slide. */
|
||||||
|
get panelCount(): number {
|
||||||
|
const requested = this.section?.layout?.columns === 2 ? 2 : 1;
|
||||||
|
return requested === 2 && this.allSlides().length > 1 ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleSlides(): HeroSlideData[] {
|
||||||
|
const slides = this.allSlides();
|
||||||
|
if (slides.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (this.panelCount === 2) {
|
||||||
|
const nextIndex = (this.activeIndex() + 1) % slides.length;
|
||||||
|
return [slides[this.activeIndex()], slides[nextIndex]];
|
||||||
|
}
|
||||||
|
const current = slides[this.activeIndex()];
|
||||||
|
return current ? [current] : [];
|
||||||
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
if (changes['data']) {
|
if (changes['data']) {
|
||||||
this.dataSignal.set(this.data);
|
this.dataSignal.set(this.data);
|
||||||
@@ -169,6 +250,41 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
this.setupAutoplay();
|
this.setupAutoplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prevSlide(): void {
|
||||||
|
const total = this.allSlides().length;
|
||||||
|
if (total <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.goTo((this.activeIndex() - 1 + total) % total);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSlide(): void {
|
||||||
|
const total = this.allSlides().length;
|
||||||
|
if (total <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.goTo((this.activeIndex() + 1) % total);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSwipeStart(event: TouchEvent): void {
|
||||||
|
this.swipeStartX = event.touches[0]?.clientX ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSwipeEnd(event: TouchEvent): void {
|
||||||
|
const startX = this.swipeStartX;
|
||||||
|
this.swipeStartX = null;
|
||||||
|
if (startX === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const endX = event.changedTouches[0]?.clientX ?? startX;
|
||||||
|
const diff = startX - endX;
|
||||||
|
if (diff > SWIPE_THRESHOLD_PX) {
|
||||||
|
this.nextSlide();
|
||||||
|
} else if (diff < -SWIPE_THRESHOLD_PX) {
|
||||||
|
this.prevSlide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onCtaClick(): void {
|
onCtaClick(): void {
|
||||||
this.ctaClicked.emit();
|
this.ctaClicked.emit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, CatalogProductGridComponent, TranslatePipe],
|
imports: [CommonModule, CatalogProductGridComponent, TranslatePipe],
|
||||||
template: `
|
template: `
|
||||||
<section class="product-carousel-widget">
|
<section class="product-carousel-widget" [style.--items-per-page]="itemsPerPage()">
|
||||||
@if (data?.title) {
|
@if (data?.title) {
|
||||||
<h2 class="product-carousel-widget__title">{{ data?.title }}</h2>
|
<h2 class="product-carousel-widget__title">{{ data?.title }}</h2>
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,8 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
|||||||
|
|
||||||
::ng-deep .catalog-product-shell {
|
::ng-deep .catalog-product-shell {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 220px;
|
width: calc((100% - (var(--items-per-page, 4) - 1) * var(--space-md, 16px)) / var(--items-per-page, 4));
|
||||||
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +122,12 @@ export class ProductCarouselWidgetComponent {
|
|||||||
return this.section?.layout?.strategy === 'carousel';
|
return this.section?.layout?.strategy === 'carousel';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Items visible per page - reuses `layout.columns` (default 4, min 1) rather than a dedicated field. */
|
||||||
|
itemsPerPage(): number {
|
||||||
|
const columns = this.section?.layout?.columns;
|
||||||
|
return columns && columns >= 1 ? Math.floor(columns) : 4;
|
||||||
|
}
|
||||||
|
|
||||||
scrollBy(direction: -1 | 1): void {
|
scrollBy(direction: -1 | 1): void {
|
||||||
const el = this.scroller?.nativeElement;
|
const el = this.scroller?.nativeElement;
|
||||||
if (!el) {
|
if (!el) {
|
||||||
|
|||||||
Reference in New Issue
Block a user