2026-07-05 02:08:15 +04:00
|
|
|
import { Injectable, inject } from '@angular/core';
|
2026-08-13 08:54:39 +04:00
|
|
|
import { Observable, of, map, switchMap, catchError } from 'rxjs';
|
2026-07-05 02:08:15 +04:00
|
|
|
import { CategoryFacade } from '../../facades/platform/category.facade';
|
|
|
|
|
import { ProductFacade } from '../../facades/platform/product.facade';
|
2026-07-19 22:44:45 +04:00
|
|
|
import { LanguageService } from '../../services/language.service';
|
|
|
|
|
import { LocalizedTextContent, SectionConfig, WidgetConfig } from '../../shared/models/config';
|
2026-07-05 02:08:15 +04:00
|
|
|
import { WidgetManifestEntry, WidgetDataSourceName } from '../contracts/widget-manifest.contract';
|
|
|
|
|
import { BannerWidgetData, CategoriesWidgetData, FooterWidgetData, HeroWidgetData, HtmlWidgetData, PartnersWidgetData, ProductCollectionWidgetData } from '../contracts/widget-data.contract';
|
|
|
|
|
import { WidgetManifestService } from '../registry/widget-manifest.service';
|
|
|
|
|
import { Category } from '../../core/categories/models/category-domain.model';
|
|
|
|
|
import { Product } from '../../core/products/models/product-domain.model';
|
|
|
|
|
|
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
|
|
|
export class DataSourceResolverService {
|
|
|
|
|
private readonly widgetManifest = inject(WidgetManifestService);
|
|
|
|
|
private readonly categoryFacade = inject(CategoryFacade);
|
|
|
|
|
private readonly productFacade = inject(ProductFacade);
|
2026-07-19 22:44:45 +04:00
|
|
|
private readonly languageService = inject(LanguageService);
|
2026-07-05 02:08:15 +04:00
|
|
|
|
|
|
|
|
resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
|
|
|
|
|
return this.widgetManifest.getWidget(widget.type).pipe(
|
2026-08-13 08:54:39 +04:00
|
|
|
switchMap((definition) => this.resolveByDefinition(definition, widget, section)),
|
|
|
|
|
catchError((error) => {
|
|
|
|
|
console.error(`Failed to resolve widget data for ${widget.type}:${widget.id}`, error);
|
|
|
|
|
return of({ section, settings: {} });
|
|
|
|
|
})
|
2026-07-05 02:08:15 +04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private resolveByDefinition(definition: WidgetManifestEntry | undefined, widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
|
|
|
|
|
const settings = this.resolveSettings(definition, widget);
|
|
|
|
|
const source = this.resolveSource(widget, settings, definition);
|
|
|
|
|
|
|
|
|
|
switch (widget.type) {
|
|
|
|
|
case 'hero':
|
|
|
|
|
return of(this.toHeroData(section, settings));
|
|
|
|
|
case 'categories':
|
|
|
|
|
return this.resolveCategories(section, widget, settings, source);
|
|
|
|
|
case 'product-collection':
|
|
|
|
|
case 'product-carousel':
|
|
|
|
|
return this.resolveProductCollection(section, widget, settings, source);
|
|
|
|
|
case 'banner':
|
|
|
|
|
return of(this.toBannerData(section, settings));
|
|
|
|
|
case 'html':
|
|
|
|
|
return of(this.toHtmlData(section, settings));
|
|
|
|
|
case 'partners':
|
|
|
|
|
return of(this.toPartnersData(section, settings));
|
|
|
|
|
case 'footer':
|
|
|
|
|
case 'footer-navigation':
|
|
|
|
|
return of(this.toFooterData(section, settings));
|
2026-07-09 01:13:54 +04:00
|
|
|
case 'recently-viewed':
|
|
|
|
|
return of({ section, settings });
|
2026-07-05 02:08:15 +04:00
|
|
|
default:
|
|
|
|
|
return of({ section, settings });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private resolveSettings(definition: WidgetManifestEntry | undefined, widget: WidgetConfig): Record<string, unknown> {
|
2026-07-09 02:29:12 +04:00
|
|
|
const metadataSettings: Record<string, unknown> = {};
|
|
|
|
|
|
|
|
|
|
if (widget.title != null) {
|
|
|
|
|
metadataSettings['title'] = widget.title;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (widget.subtitle != null) {
|
|
|
|
|
metadataSettings['subtitle'] = widget.subtitle;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (widget.animation != null) {
|
|
|
|
|
metadataSettings['animation'] = widget.animation;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (widget.style != null) {
|
|
|
|
|
metadataSettings['style'] = widget.style;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (widget.permissions != null) {
|
|
|
|
|
metadataSettings['permissions'] = widget.permissions;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 02:08:15 +04:00
|
|
|
return {
|
|
|
|
|
...(definition?.defaultSettings ?? {}),
|
2026-07-09 02:29:12 +04:00
|
|
|
...metadataSettings,
|
2026-07-05 02:08:15 +04:00
|
|
|
...(widget.props ?? {})
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private resolveSource(widget: WidgetConfig, settings: Record<string, unknown>, definition?: WidgetManifestEntry): WidgetDataSourceName | undefined {
|
|
|
|
|
const source = settings['source'] ?? widget.props?.['source'] ?? definition?.defaultSettings?.['source'];
|
|
|
|
|
return typeof source === 'string' ? source as WidgetDataSourceName : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 22:44:45 +04:00
|
|
|
/**
|
|
|
|
|
* Hero text props accept either a plain string (existing tenants, unchanged) or a
|
|
|
|
|
* per-locale map (`{ ru: '...', en: '...' }`, same shape as NavigationLocalizedText)
|
|
|
|
|
* so the hero doesn't render in one hardcoded language on every locale.
|
|
|
|
|
*/
|
|
|
|
|
private resolveLocalizedWidgetText(value: unknown): string | undefined {
|
|
|
|
|
if (value == null) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
if (typeof value !== 'object') {
|
|
|
|
|
return String(value);
|
|
|
|
|
}
|
|
|
|
|
const lang = this.languageService.currentLanguage();
|
|
|
|
|
const text = value as LocalizedTextContent;
|
|
|
|
|
return text[lang] ?? text['en'] ?? Object.values(text)[0];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 02:08:15 +04:00
|
|
|
private toHeroData(section: SectionConfig, settings: Record<string, unknown>): HeroWidgetData {
|
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
2026-07-19 14:14:29 +04:00
|
|
|
const rawSlides = settings['slides'];
|
|
|
|
|
const slides = Array.isArray(rawSlides)
|
|
|
|
|
? rawSlides
|
|
|
|
|
.filter((slide): slide is Record<string, unknown> => !!slide && typeof slide === 'object')
|
|
|
|
|
.map(slide => ({
|
2026-07-19 22:44:45 +04:00
|
|
|
title: this.resolveLocalizedWidgetText(slide['title']) ?? '',
|
|
|
|
|
subtitle: this.resolveLocalizedWidgetText(slide['subtitle']),
|
|
|
|
|
ctaLabel: this.resolveLocalizedWidgetText(slide['ctaLabel']),
|
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
2026-07-19 14:14:29 +04:00
|
|
|
}))
|
|
|
|
|
.filter(slide => slide.title)
|
|
|
|
|
: undefined;
|
|
|
|
|
|
2026-07-05 02:08:15 +04:00
|
|
|
return {
|
|
|
|
|
section,
|
|
|
|
|
settings,
|
2026-07-19 22:44:45 +04:00
|
|
|
title: this.resolveLocalizedWidgetText(settings['title']) ?? '',
|
|
|
|
|
subtitle: this.resolveLocalizedWidgetText(settings['subtitle']),
|
|
|
|
|
ctaLabel: this.resolveLocalizedWidgetText(settings['ctaLabel']),
|
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
2026-07-19 14:14:29 +04:00
|
|
|
slides,
|
|
|
|
|
autoplay: settings['autoplay'] === true,
|
2026-07-05 02:08:15 +04:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private resolveCategories(section: SectionConfig, widget: WidgetConfig, settings: Record<string, unknown>, source?: WidgetDataSourceName): Observable<CategoriesWidgetData> {
|
|
|
|
|
const title = String(settings['title'] ?? '');
|
|
|
|
|
const emptyMessage = settings['emptyMessage'] != null ? String(settings['emptyMessage']) : undefined;
|
|
|
|
|
|
|
|
|
|
switch (source) {
|
|
|
|
|
case 'parent': {
|
|
|
|
|
const parentId = this.toNumber(settings['parentId']);
|
|
|
|
|
if (parentId == null) {
|
|
|
|
|
return of({ section, settings, title, categories: [], emptyMessage });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.categoryFacade.getChildren(parentId).pipe(
|
|
|
|
|
map((categories) => ({ section, settings, title, categories, emptyMessage }))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
case 'manual': {
|
|
|
|
|
const categories = Array.isArray(settings['categories']) ? settings['categories'] as Category[] : [];
|
|
|
|
|
return of({ section, settings, title, categories, emptyMessage });
|
|
|
|
|
}
|
|
|
|
|
case 'future':
|
|
|
|
|
return of({ section, settings, title, categories: [], emptyMessage });
|
|
|
|
|
case 'root':
|
|
|
|
|
default:
|
|
|
|
|
return this.categoryFacade.getRootCategories().pipe(
|
|
|
|
|
map((categories) => ({ section, settings, title, categories, emptyMessage }))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private resolveProductCollection(section: SectionConfig, widget: WidgetConfig, settings: Record<string, unknown>, source?: WidgetDataSourceName): Observable<ProductCollectionWidgetData> {
|
|
|
|
|
const title = String(settings['title'] ?? '');
|
|
|
|
|
const actionLabel = settings['actionLabel'] != null ? String(settings['actionLabel']) : undefined;
|
|
|
|
|
const emptyMessage = settings['emptyMessage'] != null ? String(settings['emptyMessage']) : undefined;
|
|
|
|
|
const count = this.toNumber(settings['count']) ?? 8;
|
|
|
|
|
const categoryId = this.toNumber(settings['categoryId']);
|
|
|
|
|
|
|
|
|
|
switch (source) {
|
|
|
|
|
case 'latest':
|
|
|
|
|
return this.productFacade.getLatestProducts({ count }).pipe(
|
|
|
|
|
map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage))
|
|
|
|
|
);
|
|
|
|
|
case 'category':
|
|
|
|
|
if (categoryId == null) {
|
|
|
|
|
return of(this.toProductCollectionData(section, settings, title, [], actionLabel, emptyMessage));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.productFacade.getProductsByCategory(categoryId, { count }).pipe(
|
|
|
|
|
map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage))
|
|
|
|
|
);
|
|
|
|
|
case 'related': {
|
|
|
|
|
const productId = this.toNumber(settings['productId']);
|
|
|
|
|
if (productId == null) {
|
|
|
|
|
return of(this.toProductCollectionData(section, settings, title, [], actionLabel, emptyMessage));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.productFacade.getRelatedProducts({
|
|
|
|
|
productID: productId,
|
|
|
|
|
categoryID: categoryId ?? undefined,
|
|
|
|
|
count
|
|
|
|
|
}).pipe(
|
|
|
|
|
map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
case 'manual': {
|
|
|
|
|
const products = Array.isArray(settings['products']) ? settings['products'] as Product[] : [];
|
|
|
|
|
return of(this.toProductCollectionData(section, settings, title, products, actionLabel, emptyMessage));
|
|
|
|
|
}
|
|
|
|
|
case 'future':
|
|
|
|
|
return of(this.toProductCollectionData(section, settings, title, [], actionLabel, emptyMessage));
|
|
|
|
|
case 'featured':
|
|
|
|
|
default:
|
|
|
|
|
return this.productFacade.getFeaturedProducts({ count }).pipe(
|
|
|
|
|
map((result) => this.toProductCollectionData(section, settings, title, result.items, actionLabel, emptyMessage))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toProductCollectionData(section: SectionConfig, settings: Record<string, unknown>, title: string, products: Product[], actionLabel?: string, emptyMessage?: string): ProductCollectionWidgetData {
|
|
|
|
|
return {
|
|
|
|
|
section,
|
|
|
|
|
settings,
|
|
|
|
|
title,
|
|
|
|
|
products,
|
|
|
|
|
actionLabel,
|
|
|
|
|
emptyMessage
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toBannerData(section: SectionConfig, settings: Record<string, unknown>): BannerWidgetData {
|
|
|
|
|
return {
|
|
|
|
|
section,
|
|
|
|
|
settings,
|
|
|
|
|
title: settings['title'] != null ? String(settings['title']) : undefined,
|
|
|
|
|
subtitle: settings['subtitle'] != null ? String(settings['subtitle']) : undefined,
|
|
|
|
|
imageUrl: settings['imageUrl'] != null ? String(settings['imageUrl']) : undefined,
|
|
|
|
|
ctaLabel: settings['ctaLabel'] != null ? String(settings['ctaLabel']) : undefined,
|
|
|
|
|
ctaHref: settings['ctaHref'] != null ? String(settings['ctaHref']) : undefined
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toHtmlData(section: SectionConfig, settings: Record<string, unknown>): HtmlWidgetData {
|
|
|
|
|
return {
|
|
|
|
|
section,
|
|
|
|
|
settings,
|
|
|
|
|
html: String(settings['html'] ?? '')
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toPartnersData(section: SectionConfig, settings: Record<string, unknown>): PartnersWidgetData {
|
|
|
|
|
const logos = Array.isArray(settings['logos']) ? settings['logos'] as PartnersWidgetData['logos'] : [];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
section,
|
|
|
|
|
settings,
|
|
|
|
|
title: settings['title'] != null ? String(settings['title']) : undefined,
|
|
|
|
|
logos
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toFooterData(section: SectionConfig, settings: Record<string, unknown>): FooterWidgetData {
|
|
|
|
|
const links = Array.isArray(settings['links']) ? settings['links'] as FooterWidgetData['links'] : [];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
section,
|
|
|
|
|
settings,
|
|
|
|
|
links
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private toNumber(value: unknown): number | null {
|
|
|
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|