feat(product): add reusable Product Experience 2.0

Unify product details modules behind config-driven contracts so teams can
extend UX without changing runtime architecture or bootstrap flow.

Keep backward compatibility with existing product payloads by treating new
media/specification/variant/related structures as optional extensions.

Improve conversion and content discoverability with reusable actions,
typed media rendering, grouped specifications, dynamic variants, and
multi-collection related products.
This commit is contained in:
sdarbinyan
2026-07-10 13:10:35 +04:00
parent 86de2cc45b
commit aed0a47388
37 changed files with 1257 additions and 77 deletions

View File

@@ -98,6 +98,15 @@ export class ApiService {
return c.startsWith('0x') ? '#' + c.slice(2) : c;
}
private normalizeMediaType(type: unknown): 'image' | 'video' | 'pdf' | 'manual' | 'warranty' {
const value = String(type ?? '').toLowerCase();
if (value === 'video') return 'video';
if (value === 'pdf') return 'pdf';
if (value === 'manual') return 'manual';
if (value === 'warranty') return 'warranty';
return 'image';
}
private normalizeDeliveryData(
raw: any,
legacyDeliveryPrice?: number
@@ -256,6 +265,29 @@ export class ApiService {
item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u))
|| item.photos?.map((p: any) => p.url) || [];
const rawMedia = Array.isArray(raw.media) ? raw.media : [];
const mediaFromPhotos = (item.photos ?? []).map((photo: any, index: number) => ({
id: photo.id ?? `photo-${item.itemID}-${index}`,
type: this.normalizeMediaType(photo.type ?? (photo.video ? 'video' : 'image')),
url: this.resolveImageUrl(photo.url),
thumbnailUrl: photo.thumbnailUrl ? this.resolveImageUrl(photo.thumbnailUrl) : undefined,
alt: photo.alt,
title: photo.title,
labels: photo.labels
}));
item.media = (rawMedia.length > 0 ? rawMedia : mediaFromPhotos)
.map((entry: any, index: number) => ({
id: entry.id ?? `media-${item.itemID}-${index}`,
type: this.normalizeMediaType(entry.type),
url: this.resolveImageUrl(entry.url),
thumbnailUrl: entry.thumbnailUrl ? this.resolveImageUrl(entry.thumbnailUrl) : undefined,
alt: entry.alt,
title: entry.title,
labels: entry.labels,
}))
.filter((entry: any) => !!entry.url);
// Map backOffice description (key-value array) → legacy description string
if (Array.isArray(raw.description)) {
item.descriptionFields = raw.description;
@@ -287,6 +319,51 @@ export class ApiService {
// Preserve attributes from backend
item.attributes = raw.attributes || [];
item.specificationGroups = Array.isArray(raw.specificationGroups)
? raw.specificationGroups.map((group: any, groupIndex: number) => ({
id: group.id ?? `group-${groupIndex}`,
key: group.key ?? `group-${groupIndex}`,
label: group.label,
labels: group.labels,
attributes: Array.isArray(group.attributes)
? group.attributes.map((attribute: any, attributeIndex: number) => ({
key: attribute.key ?? `attribute-${attributeIndex}`,
value: String(attribute.value ?? ''),
label: attribute.label,
labels: attribute.labels,
unit: attribute.unit,
}))
: []
}))
: [];
item.variantOptions = Array.isArray(raw.variantOptions)
? raw.variantOptions.map((group: any, groupIndex: number) => ({
key: group.key ?? `variant-${groupIndex}`,
label: group.label,
labels: group.labels,
options: Array.isArray(group.options)
? group.options.map((option: any) => ({
value: String(option.value ?? ''),
label: option.label,
labels: option.labels,
available: option.available !== false,
})).filter((option: any) => option.value.length > 0)
: []
})).filter((group: any) => group.options.length > 0)
: [];
item.relatedCollections = Array.isArray(raw.relatedCollections)
? raw.relatedCollections.map((collection: any, collectionIndex: number) => ({
id: String(collection.id ?? `related-${collectionIndex}`),
title: String(collection.title ?? ''),
titles: collection.titles,
products: Array.isArray(collection.products)
? collection.products.map((productId: any) => Number(productId)).filter((productId: number) => Number.isFinite(productId))
: []
}))
: [];
// Preserve colour & size (only if not already set from itemDetails)
if (!item.colour) item.colour = this.normalizeColor(raw.colour || '');
if (!item.size) item.size = raw.size || '';