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

@@ -155,3 +155,118 @@ npm run build
## Stop Point ## Stop Point
Product Details Module implementation is complete for Sprint 6. Stop here for approval before starting any further module work. Product Details Module implementation is complete for Sprint 6. Stop here for approval before starting any further module work.
## Sprint 11 - Product Experience 2.0
Sprint 11 extends Product Details UX with reusable modules and config-driven behavior. Architecture, Widget Manifest, Section Engine, bootstrap loading, authentication, and payment logic remain unchanged.
### New Reusable Modules
- Product Actions module
- `Add to Cart`, `Buy Now`, `Wishlist`, `Compare`, `Share`, `Notify Me`
- `productPage.actions` controls visibility of each action
- Product Gallery upgrade
- Media renderer by type (`image`, `video`, `pdf`, `manual`, `warranty`)
- Thumbnail strip + active media
- Mobile swipe support
- Zoom/fullscreen extension events (`zoomRequested`, `fullscreenRequested`)
- Product Specifications upgrade
- Grouped attributes via `specificationGroups`
- Translated labels via `labels` map
- Backward-compatible fallback to `descriptionFields` and `attributes`
- Variant Selector upgrade
- Dynamic option groups via `variantOptions`
- No hardcoded variant keys required
- Backward-compatible color/size fallback
- Reviews upgrade
- Existing pagination preserved
- Optional `load-more` mode added via config
- Review form adds explicit validation and success state
- Questions & Answers upgrade
- Existing list/answers/accepted-answer behavior preserved
- Submission can be toggled off while component remains future-ready
- Related Products upgrade
- Supports multiple backend-provided collections
- Falls back to previous single related collection behavior
### Config Additions (backward-compatible)
`productPage` supports new optional keys:
```json
{
"reviews": {
"mode": "pages"
},
"questions": {
"allowSubmission": true
},
"actions": {
"enabled": true,
"addToCart": true,
"buyNow": true,
"wishlist": true,
"compare": true,
"share": true,
"notifyMe": true
}
}
```
### Product JSON Extension Example
```json
{
"itemID": 7812,
"name": "Laptop Pro 14",
"media": [
{ "type": "image", "url": "https://cdn.example.com/items/7812/main.jpg" },
{ "type": "video", "url": "https://cdn.example.com/items/7812/demo.mp4" },
{ "type": "pdf", "url": "https://cdn.example.com/items/7812/spec.pdf" },
{ "type": "manual", "url": "https://cdn.example.com/items/7812/manual.pdf" },
{ "type": "warranty", "url": "https://cdn.example.com/items/7812/warranty.pdf" }
],
"specificationGroups": [
{
"key": "display",
"label": "Display",
"labels": { "en": "Display", "ru": "Экран", "hy": "Էկրան" },
"attributes": [
{ "key": "size", "value": "14", "unit": "inch", "labels": { "en": "Size", "ru": "Диагональ", "hy": "Չափ" } },
{ "key": "resolution", "value": "2880x1800", "labels": { "en": "Resolution", "ru": "Разрешение", "hy": "Լուծաչափ" } }
]
}
],
"variantOptions": [
{
"key": "storage",
"label": "Storage",
"options": [
{ "value": "256GB" },
{ "value": "512GB" }
]
}
],
"relatedCollections": [
{
"id": "frequently-bought-together",
"title": "Frequently bought together",
"titles": { "ru": "Покупают вместе", "hy": "Հաճախ գնում են միասին" },
"products": [9021, 9022, 9023]
},
{
"id": "similar-products",
"title": "Similar products",
"products": [9030, 9031]
}
]
}
```
### Extension Points
- Gallery overlays can subscribe to `zoomRequested` and `fullscreenRequested` without changing gallery internals.
- Variant logic can add new option groups from backend by extending `variantOptions` only.
- Specifications can add locale labels without frontend refactor.
- Related collections can add campaign-specific blocks without changing section engine.
- Action visibility and review mode can be tuned from config without component rewrites.

View File

@@ -24,6 +24,10 @@
- attributes - attributes
- stockStatus - stockStatus
- rating - rating
- media
- specificationGroups
- variantOptions
- relatedCollections
## Product Engagement Models ## Product Engagement Models
- RatingSummary - RatingSummary
@@ -41,6 +45,26 @@
- id, text, author, createdAt - id, text, author, createdAt
- isOfficialSeller, isAccepted - isOfficialSeller, isAccepted
## Product Experience 2.0 Optional Contracts
- media: `[{ type, url, thumbnailUrl?, alt?, title?, labels? }]`
- type: `image | video | pdf | manual | warranty`
- frontend renderer picks viewer by `type`
- specificationGroups: `[{ key, label?, labels?, attributes: [{ key, value, label?, labels?, unit? }] }]`
- supports grouped specifications and translated labels
- variantOptions: `[{ key, label?, labels?, options: [{ value, label?, labels?, available? }] }]`
- supports arbitrary variant groups (`color`, `size`, `storage`, etc.)
- relatedCollections: `[{ id, title, titles?, products: number[] }]`
- supports multiple related collections from backend
## Product Page Config Extensions
`productPage` optional config additions:
- reviews.mode: `pages | load-more`
- questions.allowSubmission: `boolean`
- actions: `{ enabled, addToCart, buyNow, wishlist, compare, share, notifyMe }`
## Строгие правила ## Строгие правила
- Цена и валюта должны передаваться как валидная пара. - Цена и валюта должны передаваться как валидная пара.
- Скрытые товары не участвуют в публичных витринах. - Скрытые товары не участвуют в публичных витринах.
@@ -63,7 +87,35 @@
"images": [ "images": [
{ "url": "https://cdn.example.com/items/7812/main.jpg", "isMain": true } { "url": "https://cdn.example.com/items/7812/main.jpg", "isMain": true }
], ],
"badges": ["featured", "new"] "badges": ["featured", "new"],
"media": [
{ "type": "image", "url": "https://cdn.example.com/items/7812/main.jpg" },
{ "type": "video", "url": "https://cdn.example.com/items/7812/demo.mp4" },
{ "type": "pdf", "url": "https://cdn.example.com/items/7812/spec.pdf" }
],
"specificationGroups": [
{
"key": "display",
"labels": { "en": "Display", "ru": "Экран" },
"attributes": [
{ "key": "size", "value": "14", "unit": "inch" },
{ "key": "resolution", "value": "2880x1800" }
]
}
],
"variantOptions": [
{
"key": "storage",
"options": [{ "value": "256GB" }, { "value": "512GB" }]
}
],
"relatedCollections": [
{
"id": "similar-products",
"title": "Похожие товары",
"products": [9030, 9031]
}
]
} }
``` ```
@@ -76,3 +128,4 @@
- Возвращать актуальные цены и доступность. - Возвращать актуальные цены и доступность.
- Стабильно поддерживать идентификаторы товаров. - Стабильно поддерживать идентификаторы товаров.
- Предоставлять медиа и атрибуты в согласованном формате. - Предоставлять медиа и атрибуты в согласованном формате.
- Поддерживать обратную совместимость: новые поля опциональны, старые payload остаются валидными.

View File

@@ -1,6 +1,12 @@
{ {
"version": 1, "version": 1,
"skills": { "skills": {
"angular-developer": {
"source": "angular/skills",
"sourceType": "github",
"skillPath": "angular-developer/SKILL.md",
"computedHash": "62e087c9cf0dc17f4ca4fed9f451f65605f43e4427016eb799409d6da39a0a87"
},
"cavecrew": { "cavecrew": {
"source": "JuliusBrussee/caveman", "source": "JuliusBrussee/caveman",
"sourceType": "github", "sourceType": "github",
@@ -42,6 +48,12 @@
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/caveman-stats/SKILL.md", "skillPath": "skills/caveman-stats/SKILL.md",
"computedHash": "331f720e2fa97b68cacdae44384878071e8cac6013479edea68f4c8eca308852" "computedHash": "331f720e2fa97b68cacdae44384878071e8cac6013479edea68f4c8eca308852"
},
"design-taste-frontend": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/taste-skill/SKILL.md",
"computedHash": "899b84384f74f540ea5284d9b2e9234e050998b42eacc805410b518d4226c0b3"
} }
} }
} }

View File

@@ -36,3 +36,14 @@ export interface RelatedProductsQuery {
categoryID?: number; categoryID?: number;
count?: number; count?: number;
} }
export interface RelatedProductCollection {
id: string;
title: string;
titles?: Record<string, string>;
productIDs: number[];
}
export interface ProductVariantSelection {
[groupKey: string]: string;
}

View File

@@ -0,0 +1,25 @@
<section class="product-actions" [attr.aria-label]="'productDetails.actionsAria' | translate">
@if (config.addToCart) {
<button type="button" class="primary" (click)="addToCart.emit()">{{ 'itemDetail.addToCart' | translate }}</button>
}
@if (config.buyNow) {
<button type="button" class="primary ghost" (click)="buyNow.emit()">{{ 'productDetails.buyNow' | translate }}</button>
}
@if (config.wishlist) {
<button type="button" class="secondary" [class.active]="inWishlist" (click)="wishlist.emit()">{{ 'productDetails.wishlist' | translate }}</button>
}
@if (config.compare) {
<button type="button" class="secondary" [class.active]="inCompare" (click)="compare.emit()">{{ 'productDetails.compare' | translate }}</button>
}
@if (config.share) {
<button type="button" class="secondary" (click)="share.emit()">{{ 'productDetails.share' | translate }}</button>
}
@if (config.notifyMe && outOfStock) {
<button type="button" class="secondary" (click)="notifyMe.emit()">{{ 'productDetails.notifyMe' | translate }}</button>
}
</section>

View File

@@ -0,0 +1,43 @@
.product-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button {
min-height: 40px;
border-radius: 10px;
border: 1px solid #d3dad9;
background: #fff;
color: #1e3c38;
font-weight: 700;
padding: 0 14px;
cursor: pointer;
}
button.primary {
border-color: #497671;
background: #497671;
color: #fff;
}
button.primary.ghost {
background: #eef4f3;
color: #2e5e59;
}
button.secondary.active {
border-color: #497671;
box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.18);
}
@media (max-width: 640px) {
.product-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
button {
width: 100%;
}
}

View File

@@ -0,0 +1,41 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
export interface ProductActionsConfig {
addToCart: boolean;
buyNow: boolean;
wishlist: boolean;
compare: boolean;
share: boolean;
notifyMe: boolean;
}
@Component({
selector: 'app-product-actions',
standalone: true,
imports: [TranslatePipe],
templateUrl: './product-actions.component.html',
styleUrls: ['./product-actions.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductActionsComponent {
@Input() config: ProductActionsConfig = {
addToCart: true,
buyNow: true,
wishlist: true,
compare: true,
share: true,
notifyMe: true,
};
@Input() outOfStock = false;
@Input() inWishlist = false;
@Input() inCompare = false;
@Output() addToCart = new EventEmitter<void>();
@Output() buyNow = new EventEmitter<void>();
@Output() wishlist = new EventEmitter<void>();
@Output() compare = new EventEmitter<void>();
@Output() share = new EventEmitter<void>();
@Output() notifyMe = new EventEmitter<void>();
}

View File

@@ -1,9 +1,38 @@
<section class="product-gallery" [attr.aria-label]="'productDetails.mediaAria' | translate"> <section class="product-gallery" [attr.aria-label]="'productDetails.mediaAria' | translate">
<div class="product-gallery-main"> <div class="product-gallery-main" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
@if (media[selectedIndex]?.video) { <div class="product-gallery-toolbar">
<button type="button" class="toolbar-btn" (click)="zoomRequested.emit(selectedIndex)">{{ 'productDetails.zoom' | translate }}</button>
<button type="button" class="toolbar-btn" (click)="fullscreenRequested.emit(selectedIndex)">{{ 'productDetails.fullscreen' | translate }}</button>
</div>
@switch (media[selectedIndex]?.type) {
@case ('video') {
<video [src]="media[selectedIndex].url" controls></video> <video [src]="media[selectedIndex].url" controls></video>
} @else { }
<img [src]="media[selectedIndex].url" [alt]="product.name" loading="eager" decoding="async" />
@case ('pdf') {
<object [data]="media[selectedIndex].url" type="application/pdf" [attr.aria-label]="'productDetails.pdfDocument' | translate">
<a [href]="media[selectedIndex].url" target="_blank" rel="noopener">{{ 'productDetails.openDocument' | translate }}</a>
</object>
}
@case ('manual') {
<div class="document-viewer">
<p>{{ 'productDetails.manualDocument' | translate }}</p>
<a [href]="media[selectedIndex].url" target="_blank" rel="noopener">{{ 'productDetails.openDocument' | translate }}</a>
</div>
}
@case ('warranty') {
<div class="document-viewer">
<p>{{ 'productDetails.warrantyDocument' | translate }}</p>
<a [href]="media[selectedIndex].url" target="_blank" rel="noopener">{{ 'productDetails.openDocument' | translate }}</a>
</div>
}
@default {
<img [src]="media[selectedIndex].url" [alt]="media[selectedIndex].alt" loading="eager" decoding="async" />
}
} }
</div> </div>
@@ -11,10 +40,12 @@
<div class="product-gallery-thumbs"> <div class="product-gallery-thumbs">
@for (item of media; track $index) { @for (item of media; track $index) {
<button type="button" class="product-gallery-thumb" [class.active]="selectedIndex === $index" (click)="select($index)"> <button type="button" class="product-gallery-thumb" [class.active]="selectedIndex === $index" (click)="select($index)">
@if (item.video) { @if (item.type === 'video') {
<span class="product-gallery-video"></span> <span class="product-gallery-video"></span>
} @else if (item.type !== 'image') {
<span class="product-gallery-doc">{{ item.type }}</span>
} }
<img [src]="item.url" [alt]="product.name + ' ' + ($index + 1)" loading="lazy" decoding="async" /> <img [src]="item.thumbnailUrl" [alt]="item.alt + ' ' + ($index + 1)" loading="lazy" decoding="async" />
</button> </button>
} }
</div> </div>

View File

@@ -5,6 +5,7 @@
} }
.product-gallery-main { .product-gallery-main {
position: relative;
aspect-ratio: 1; aspect-ratio: 1;
border: 1px solid #d3dad9; border: 1px solid #d3dad9;
border-radius: 13px; border-radius: 13px;
@@ -16,10 +17,58 @@
transition: box-shadow 0.24s ease, border-color 0.24s ease; transition: box-shadow 0.24s ease, border-color 0.24s ease;
img, img,
video { video,
object {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
border: 0;
}
}
.product-gallery-toolbar {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
display: flex;
gap: 8px;
}
.toolbar-btn {
min-height: 30px;
border: 1px solid rgba(30, 60, 56, 0.25);
background: rgba(255, 255, 255, 0.9);
color: #1e3c38;
border-radius: 999px;
padding: 0 10px;
font-weight: 700;
cursor: pointer;
}
.document-viewer {
display: grid;
place-items: center;
gap: 10px;
padding: 16px;
text-align: center;
p {
margin: 0;
color: #1e3c38;
font-weight: 700;
}
a {
min-height: 38px;
border-radius: 999px;
border: 1px solid #497671;
color: #2e5e59;
text-decoration: none;
padding: 0 14px;
display: inline-flex;
align-items: center;
font-weight: 700;
} }
} }
@@ -67,6 +116,19 @@
z-index: 1; z-index: 1;
} }
.product-gallery-doc {
position: absolute;
left: 6px;
bottom: 6px;
z-index: 1;
background: rgba(30, 60, 56, 0.78);
color: #fff;
border-radius: 999px;
padding: 2px 8px;
font-size: 0.7rem;
text-transform: uppercase;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.product-gallery-thumbs { .product-gallery-thumbs {
grid-template-columns: repeat(auto-fill, minmax(58px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(58px, 1fr));

View File

@@ -3,6 +3,16 @@ import { Product } from '../../../../../core/products/models/product-domain.mode
import { getMainImage } from '../../../../../utils/item.utils'; import { getMainImage } from '../../../../../utils/item.utils';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
type ProductMediaType = 'image' | 'video' | 'pdf' | 'manual' | 'warranty';
interface GalleryMediaItem {
id: string;
type: ProductMediaType;
url: string;
thumbnailUrl: string;
alt: string;
}
@Component({ @Component({
selector: 'app-product-gallery', selector: 'app-product-gallery',
standalone: true, standalone: true,
@@ -16,14 +26,82 @@ export class ProductGalleryComponent {
@Input() selectedIndex = 0; @Input() selectedIndex = 0;
@Output() selectedIndexChange = new EventEmitter<number>(); @Output() selectedIndexChange = new EventEmitter<number>();
@Output() zoomRequested = new EventEmitter<number>();
@Output() fullscreenRequested = new EventEmitter<number>();
readonly getMainImage = getMainImage; readonly getMainImage = getMainImage;
get media() { private touchStartX = 0;
return this.product.photos?.length ? this.product.photos : [{ url: this.getMainImage(this.product) }]; private touchStartY = 0;
get media(): GalleryMediaItem[] {
if (this.product.media?.length) {
return this.product.media.map((item, index) => ({
id: item.id ?? `media-${index}`,
type: item.type,
url: item.url,
thumbnailUrl: item.thumbnailUrl ?? item.url,
alt: item.alt || item.title || this.product.name,
}));
}
if (this.product.photos?.length) {
return this.product.photos.map((item, index) => ({
id: item.photo ?? `photo-${index}`,
type: item.video ? 'video' : this.resolveLegacyType(item.type),
url: item.url,
thumbnailUrl: item.url,
alt: this.product.name,
}));
}
return [{
id: 'fallback-image',
type: 'image',
url: this.getMainImage(this.product),
thumbnailUrl: this.getMainImage(this.product),
alt: this.product.name,
}];
} }
select(index: number): void { select(index: number): void {
this.selectedIndexChange.emit(index); this.selectedIndexChange.emit(index);
} }
onTouchStart(event: TouchEvent): void {
if (event.touches.length === 0) {
return;
}
this.touchStartX = event.touches[0].clientX;
this.touchStartY = event.touches[0].clientY;
}
onTouchEnd(event: TouchEvent): void {
if (event.changedTouches.length === 0 || this.media.length <= 1) {
return;
}
const deltaX = event.changedTouches[0].clientX - this.touchStartX;
const deltaY = event.changedTouches[0].clientY - this.touchStartY;
if (Math.abs(deltaX) < 36 || Math.abs(deltaX) < Math.abs(deltaY)) {
return;
}
const direction = deltaX > 0 ? -1 : 1;
const next = Math.min(Math.max(0, this.selectedIndex + direction), this.media.length - 1);
if (next !== this.selectedIndex) {
this.selectedIndexChange.emit(next);
}
}
private resolveLegacyType(type: string | undefined): ProductMediaType {
const normalized = (type ?? '').toLowerCase();
if (normalized === 'video') return 'video';
if (normalized === 'pdf') return 'pdf';
if (normalized === 'manual') return 'manual';
if (normalized === 'warranty') return 'warranty';
return 'image';
}
} }

View File

@@ -36,7 +36,9 @@
} }
</div> </div>
@if (showAddToCart) {
<button type="button" class="add-to-cart" (click)="addToCart.emit()"> <button type="button" class="add-to-cart" (click)="addToCart.emit()">
{{ 'itemDetail.addToCart' | translate }} {{ 'itemDetail.addToCart' | translate }}
</button> </button>
}
</section> </section>

View File

@@ -20,6 +20,7 @@ export class ProductInformationComponent {
@Input() currency = ''; @Input() currency = '';
@Input() remaining: number | null = null; @Input() remaining: number | null = null;
@Input() stockStatus = 'high'; @Input() stockStatus = 'high';
@Input() showAddToCart = true;
@Output() addToCart = new EventEmitter<void>(); @Output() addToCart = new EventEmitter<void>();

View File

@@ -1,15 +1,20 @@
<section class="product-specifications card section"> <section class="product-specifications card section">
<h3>{{ 'itemDetail.specifications' | translate }}</h3> <h3>{{ 'itemDetail.specifications' | translate }}</h3>
@if (descriptionFields.length > 0) { @if (groups.length > 0) {
@for (group of groups; track group.key) {
<section class="spec-group">
<h4>{{ group.label | translate }}</h4>
<dl> <dl>
@for (field of descriptionFields; track field.key) { @for (field of group.fields; track field.key) {
<div> <div>
<dt>{{ field.key }}</dt> <dt>{{ field.label | translate }}</dt>
<dd>{{ field.value }}</dd> <dd>{{ field.value }}</dd>
</div> </div>
} }
</dl> </dl>
</section>
}
} @else { } @else {
<p>{{ 'productDetails.specificationsEmpty' | translate }}</p> <p>{{ 'productDetails.specificationsEmpty' | translate }}</p>
} }

View File

@@ -7,6 +7,18 @@ h3 {
color: var(--text-primary); color: var(--text-primary);
} }
.spec-group {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
h4 {
margin: 0;
color: var(--text-primary);
font-size: 1rem;
}
dl { dl {
margin: 0; margin: 0;
display: grid; display: grid;

View File

@@ -1,6 +1,19 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Product } from '../../../../../core/products/models/product-domain.model'; import { Product } from '../../../../../core/products/models/product-domain.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../../services/language.service';
interface SpecificationViewField {
key: string;
label: string;
value: string;
}
interface SpecificationViewGroup {
key: string;
label: string;
fields: SpecificationViewField[];
}
@Component({ @Component({
selector: 'app-product-specifications', selector: 'app-product-specifications',
@@ -13,7 +26,47 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
export class ProductSpecificationsComponent { export class ProductSpecificationsComponent {
@Input({ required: true }) product!: Product; @Input({ required: true }) product!: Product;
get descriptionFields() { constructor(private readonly languageService: LanguageService) {}
return this.product.descriptionFields ?? [];
get groups(): SpecificationViewGroup[] {
const locale = this.languageService.currentLanguage();
const groups = this.product.specificationGroups ?? [];
if (groups.length > 0) {
return groups
.map(group => ({
key: group.key,
label: group.labels?.[locale] || group.label || group.key,
fields: (group.attributes ?? []).map(attribute => ({
key: attribute.key,
label: attribute.labels?.[locale] || attribute.label || attribute.key,
value: attribute.unit ? `${attribute.value} ${attribute.unit}` : attribute.value,
}))
}))
.filter(group => group.fields.length > 0);
}
const fallbackFields = [
...(this.product.descriptionFields ?? []).map(field => ({
key: field.key,
label: field.key,
value: field.value,
})),
...(this.product.attributes ?? []).map(attribute => ({
key: attribute.key,
label: attribute.key,
value: attribute.value,
}))
];
if (fallbackFields.length === 0) {
return [];
}
return [{
key: 'general',
label: 'productDetails.specificationsGeneral',
fields: fallbackFields,
}];
} }
} }

View File

@@ -1,8 +1,9 @@
@if (products.length > 0) { @if (normalizedCollections.length > 0) {
@for (collection of normalizedCollections; track collection.id) {
<section class="related-products"> <section class="related-products">
<h2>{{ 'itemDetail.relatedProducts' | translate }}</h2> <h2>{{ collection.title | translate }}</h2>
<div class="related-grid"> <div class="related-grid">
@for (product of products; track trackByItemId($index, product)) { @for (product of collection.products; track trackByItemId($index, product)) {
<app-product-card <app-product-card
[item]="product" [item]="product"
[title]="productTitle(product)" [title]="productTitle(product)"
@@ -14,4 +15,5 @@
} }
</div> </div>
</section> </section>
}
} }

View File

@@ -5,6 +5,12 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../../services/language.service'; import { LanguageService } from '../../../../../services/language.service';
import { getTranslatedField, trackByItemId } from '../../../../../utils/item.utils'; import { getTranslatedField, trackByItemId } from '../../../../../utils/item.utils';
export interface RelatedCollectionViewModel {
id: string;
title: string;
products: Product[];
}
@Component({ @Component({
selector: 'app-related-products', selector: 'app-related-products',
standalone: true, standalone: true,
@@ -15,6 +21,7 @@ import { getTranslatedField, trackByItemId } from '../../../../../utils/item.uti
}) })
export class RelatedProductsComponent { export class RelatedProductsComponent {
@Input() products: Product[] = []; @Input() products: Product[] = [];
@Input() collections: RelatedCollectionViewModel[] = [];
@Output() productSelected = new EventEmitter<Product>(); @Output() productSelected = new EventEmitter<Product>();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@@ -23,6 +30,22 @@ export class RelatedProductsComponent {
readonly trackByItemId = trackByItemId; readonly trackByItemId = trackByItemId;
get normalizedCollections(): RelatedCollectionViewModel[] {
if (this.collections.length > 0) {
return this.collections.filter(collection => collection.products.length > 0);
}
if (this.products.length === 0) {
return [];
}
return [{
id: 'default-related-products',
title: 'itemDetail.relatedProducts',
products: this.products,
}];
}
productTitle(product: Product): string { productTitle(product: Product): string {
return getTranslatedField(product, 'name', this.languageService.currentLanguage()); return getTranslatedField(product, 'name', this.languageService.currentLanguage());
} }

View File

@@ -1,31 +1,28 @@
@if (hasVariants) { @if (hasVariants) {
<section class="variant-selector"> <section class="variant-selector">
@if (colours.length > 0) { @for (group of normalizedGroups; track group.key) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span> <span class="variant-label">{{ group.label | translate }}</span>
<div class="variant-options"> <div class="variant-options">
@for (colour of colours; track $index) { @for (option of group.options; track option.value) {
<button type="button" class="colour-swatch" [class.active]="selectedColour === colour" [style.background-color]="colour" [attr.aria-label]="colour" (click)="colourSelected.emit(colour)"></button> @if (group.key === 'colour') {
<button type="button" class="colour-swatch" [class.active]="isActive(group.key, option.value)" [style.background-color]="option.value" [attr.aria-label]="option.label || option.value" (click)="onSelect(group.key, option.value)"></button>
} @else {
<button type="button" class="size-chip" [class.active]="isActive(group.key, option.value)" (click)="onSelect(group.key, option.value)">{{ option.label || option.value }}</button>
}
} }
</div> </div>
</div> </div>
} @else if (fallbackColour) { }
@if (normalizedGroups.length === 0 && fallbackColour) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span> <span class="variant-label">{{ 'itemDetail.colour' | translate }}</span>
<span class="colour-swatch readonly" [style.background-color]="fallbackColour"></span> <span class="colour-swatch readonly" [style.background-color]="fallbackColour"></span>
</div> </div>
} }
@if (sizes.length > 0) { @if (normalizedGroups.length === 0 && visibleFallbackSize) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}</span>
<div class="variant-options">
@for (size of sizes; track $index) {
<button type="button" class="size-chip" [class.active]="selectedSize === size" (click)="sizeSelected.emit(size)">{{ size }}</button>
}
</div>
</div>
} @else if (visibleFallbackSize) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}</span> <span class="variant-label">{{ 'itemDetail.size' | translate }}</span>
<span class="size-chip readonly">{{ visibleFallbackSize }}</span> <span class="size-chip readonly">{{ visibleFallbackSize }}</span>

View File

@@ -1,6 +1,17 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
export interface VariantOption {
value: string;
label?: string;
}
export interface VariantOptionGroup {
key: string;
label: string;
options: VariantOption[];
}
@Component({ @Component({
selector: 'app-product-variant-selector', selector: 'app-product-variant-selector',
standalone: true, standalone: true,
@@ -10,6 +21,9 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ProductVariantSelectorComponent { export class ProductVariantSelectorComponent {
@Input() optionGroups: VariantOptionGroup[] = [];
@Input() selectedOptions: Record<string, string> = {};
@Input() colours: string[] = []; @Input() colours: string[] = [];
@Input() sizes: string[] = []; @Input() sizes: string[] = [];
@Input() selectedColour: string | null = null; @Input() selectedColour: string | null = null;
@@ -17,11 +31,62 @@ export class ProductVariantSelectorComponent {
@Input() fallbackColour: string | null = null; @Input() fallbackColour: string | null = null;
@Input() fallbackSize: string | null = null; @Input() fallbackSize: string | null = null;
@Output() optionSelected = new EventEmitter<{ groupKey: string; value: string }>();
@Output() colourSelected = new EventEmitter<string>(); @Output() colourSelected = new EventEmitter<string>();
@Output() sizeSelected = new EventEmitter<string>(); @Output() sizeSelected = new EventEmitter<string>();
get hasVariants(): boolean { get hasVariants(): boolean {
return this.colours.length > 0 || this.sizes.length > 0 || !!this.fallbackColour || !!this.visibleFallbackSize; return this.normalizedGroups.length > 0 || !!this.fallbackColour || !!this.visibleFallbackSize;
}
get normalizedGroups(): VariantOptionGroup[] {
if (this.optionGroups.length > 0) {
return this.optionGroups;
}
const groups: VariantOptionGroup[] = [];
if (this.colours.length > 0) {
groups.push({
key: 'colour',
label: 'itemDetail.colour',
options: this.colours.map(value => ({ value }))
});
}
if (this.sizes.length > 0) {
groups.push({
key: 'size',
label: 'itemDetail.size',
options: this.sizes.map(value => ({ value }))
});
}
return groups;
}
isActive(groupKey: string, value: string): boolean {
if (groupKey === 'colour') {
return this.selectedColour === value;
}
if (groupKey === 'size') {
return this.selectedSize === value;
}
return this.selectedOptions[groupKey] === value;
}
onSelect(groupKey: string, value: string): void {
this.optionSelected.emit({ groupKey, value });
if (groupKey === 'colour') {
this.colourSelected.emit(value);
return;
}
if (groupKey === 'size') {
this.sizeSelected.emit(value);
}
} }
get visibleFallbackSize(): string | null { get visibleFallbackSize(): string | null {

View File

@@ -40,16 +40,32 @@
[currency]="effectiveCurrency()" [currency]="effectiveCurrency()"
[remaining]="effectiveRemaining()" [remaining]="effectiveRemaining()"
[stockStatus]="stockStatus()" [stockStatus]="stockStatus()"
[showAddToCart]="false"
(addToCart)="addToCart()" (addToCart)="addToCart()"
/> />
<app-product-actions
[config]="actionsConfig()"
[outOfStock]="stockStatus() === 'out'"
[inWishlist]="isInWishlist()"
[inCompare]="isInCompare()"
(addToCart)="addToCart()"
(buyNow)="buyNow()"
(wishlist)="toggleWishlist()"
(compare)="toggleCompare()"
(share)="shareProduct()"
(notifyMe)="notifyMe()" />
<app-product-variant-selector <app-product-variant-selector
[optionGroups]="variantOptionGroups()"
[selectedOptions]="selectedOptions()"
[colours]="availableColours()" [colours]="availableColours()"
[sizes]="availableSizes()" [sizes]="availableSizes()"
[selectedColour]="selectedColour()" [selectedColour]="selectedColour()"
[selectedSize]="selectedSize()" [selectedSize]="selectedSize()"
[fallbackColour]="currentProduct.colour ?? null" [fallbackColour]="currentProduct.colour ?? null"
[fallbackSize]="currentProduct.size ?? null" [fallbackSize]="currentProduct.size ?? null"
(optionSelected)="selectVariantOption($event)"
(colourSelected)="selectColour($event)" (colourSelected)="selectColour($event)"
(sizeSelected)="selectSize($event)" (sizeSelected)="selectSize($event)"
/> />
@@ -82,7 +98,9 @@
[loading]="reviewsLoading() || ratingLoading()" [loading]="reviewsLoading() || ratingLoading()"
[submitting]="reviewSubmitting()" [submitting]="reviewSubmitting()"
[showSummary]="productPageConfigState().reviews.showSummary !== false" [showSummary]="productPageConfigState().reviews.showSummary !== false"
[mode]="reviewsMode()"
(pageChange)="loadReviews($event)" (pageChange)="loadReviews($event)"
(loadMore)="loadReviews($event, true)"
(submitReview)="submitReview($event)" /> (submitReview)="submitReview($event)" />
} }
@@ -91,6 +109,7 @@
[result]="questionsResult()" [result]="questionsResult()"
[loading]="questionsLoading()" [loading]="questionsLoading()"
[submitting]="questionSubmitting()" [submitting]="questionSubmitting()"
[allowSubmission]="productPageConfigState().questions.allowSubmission !== false"
(pageChange)="loadQuestions($event)" (pageChange)="loadQuestions($event)"
(submitQuestion)="submitQuestion($event)" /> (submitQuestion)="submitQuestion($event)" />
} }
@@ -122,7 +141,9 @@
[loading]="reviewsLoading() || ratingLoading()" [loading]="reviewsLoading() || ratingLoading()"
[submitting]="reviewSubmitting()" [submitting]="reviewSubmitting()"
[showSummary]="productPageConfigState().reviews.showSummary !== false" [showSummary]="productPageConfigState().reviews.showSummary !== false"
[mode]="reviewsMode()"
(pageChange)="loadReviews($event)" (pageChange)="loadReviews($event)"
(loadMore)="loadReviews($event, true)"
(submitReview)="submitReview($event)" /> (submitReview)="submitReview($event)" />
} }
@@ -131,6 +152,7 @@
[result]="questionsResult()" [result]="questionsResult()"
[loading]="questionsLoading()" [loading]="questionsLoading()"
[submitting]="questionSubmitting()" [submitting]="questionSubmitting()"
[allowSubmission]="productPageConfigState().questions.allowSubmission !== false"
(pageChange)="loadQuestions($event)" (pageChange)="loadQuestions($event)"
(submitQuestion)="submitQuestion($event)" /> (submitQuestion)="submitQuestion($event)" />
} }
@@ -148,6 +170,7 @@
@if (productPageConfigState().relatedProducts.enabled !== false) { @if (productPageConfigState().relatedProducts.enabled !== false) {
<app-related-products <app-related-products
[products]="relatedProducts()" [products]="relatedProducts()"
[collections]="relatedCollections()"
(productSelected)="selectRelatedProduct($event)" (productSelected)="selectRelatedProduct($event)"
(addToCart)="addRelatedToCart($event)" (addToCart)="addRelatedToCart($event)"
/> />

View File

@@ -1,6 +1,8 @@
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Product } from '../../../../core/products/models/product-domain.model'; import { Product } from '../../../../core/products/models/product-domain.model';
import { EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../../../core/products/models/product-engagement.model'; import { EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../../../core/products/models/product-engagement.model';
import { ConfigService } from '../../../../core/config/config.service'; import { ConfigService } from '../../../../core/config/config.service';
@@ -13,14 +15,16 @@ import { CartService } from '../../../../services';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config'; import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils'; import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
import { ProductShareService } from '../../user-experience/services/product-share.service';
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component'; import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
import { ProductActionsComponent } from '../components/product-actions/product-actions.component';
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component'; import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
import { ProductInformationComponent } from '../components/product-information/product-information.component'; import { ProductInformationComponent } from '../components/product-information/product-information.component';
import { ProductDescriptionComponent } from '../components/product-description/product-description.component'; import { ProductDescriptionComponent } from '../components/product-description/product-description.component';
import { ProductSpecificationsComponent } from '../components/product-specifications/product-specifications.component'; import { ProductSpecificationsComponent } from '../components/product-specifications/product-specifications.component';
import { ProductWarrantyComponent } from '../components/product-warranty/product-warranty.component'; import { ProductWarrantyComponent } from '../components/product-warranty/product-warranty.component';
import { RelatedProductsComponent } from '../components/related-products/related-products.component'; import { RelatedCollectionViewModel, RelatedProductsComponent } from '../components/related-products/related-products.component';
import { ProductVariantSelectorComponent } from '../components/variant-selector/variant-selector.component'; import { ProductVariantSelectorComponent, VariantOptionGroup } from '../components/variant-selector/variant-selector.component';
import { ProductTabItem, ProductTabKey, ProductTabsComponent } from '../engagement/components/product-tabs/product-tabs.component'; import { ProductTabItem, ProductTabKey, ProductTabsComponent } from '../engagement/components/product-tabs/product-tabs.component';
import { QuestionListComponent } from '../engagement/components/question-list/question-list.component'; import { QuestionListComponent } from '../engagement/components/question-list/question-list.component';
import { ReviewListComponent } from '../engagement/components/review-list/review-list.component'; import { ReviewListComponent } from '../engagement/components/review-list/review-list.component';
@@ -32,6 +36,7 @@ import { ReviewListComponent } from '../engagement/components/review-list/review
RouterLink, RouterLink,
LangRoutePipe, LangRoutePipe,
TranslatePipe, TranslatePipe,
ProductActionsComponent,
ProductGalleryComponent, ProductGalleryComponent,
ProductInformationComponent, ProductInformationComponent,
ProductVariantSelectorComponent, ProductVariantSelectorComponent,
@@ -58,13 +63,16 @@ export class ProductDetailsContainerComponent {
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly shareService = inject(ProductShareService);
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig()); readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig()); readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
readonly product = signal<Product | null>(null); readonly product = signal<Product | null>(null);
readonly relatedProducts = signal<Product[]>([]); readonly relatedProducts = signal<Product[]>([]);
readonly relatedCollections = signal<RelatedCollectionViewModel[]>([]);
readonly selectedPhotoIndex = signal(0); readonly selectedPhotoIndex = signal(0);
readonly selectedOptions = signal<Record<string, string>>({});
readonly selectedColour = signal<string | null>(null); readonly selectedColour = signal<string | null>(null);
readonly selectedSize = signal<string | null>(null); readonly selectedSize = signal<string | null>(null);
readonly loading = signal(true); readonly loading = signal(true);
@@ -85,6 +93,19 @@ export class ProductDetailsContainerComponent {
readonly reviewsPageSize = computed(() => this.productPageConfigState().reviews.pageSize ?? 5); readonly reviewsPageSize = computed(() => this.productPageConfigState().reviews.pageSize ?? 5);
readonly questionsPageSize = computed(() => this.productPageConfigState().questions.pageSize ?? 5); readonly questionsPageSize = computed(() => this.productPageConfigState().questions.pageSize ?? 5);
readonly reviewsMode = computed(() => this.productPageConfigState().reviews.mode ?? 'pages');
readonly actionsConfig = computed(() => {
const config = this.productPageConfigState().actions;
return {
addToCart: config.enabled !== false && config.addToCart !== false,
buyNow: config.enabled !== false && config.buyNow !== false,
wishlist: config.enabled !== false && config.wishlist !== false,
compare: config.enabled !== false && config.compare !== false,
share: config.enabled !== false && config.share !== false,
notifyMe: config.enabled !== false && config.notifyMe !== false,
};
});
readonly tabsEnabled = computed(() => this.productPageConfigState().tabs.enabled !== false); readonly tabsEnabled = computed(() => this.productPageConfigState().tabs.enabled !== false);
readonly tabs = computed<ProductTabItem[]>(() => { readonly tabs = computed<ProductTabItem[]>(() => {
@@ -122,12 +143,61 @@ export class ProductDetailsContainerComponent {
if (!details?.length) return null; if (!details?.length) return null;
const colour = this.selectedColour(); const colour = this.selectedColour();
const size = this.selectedSize(); const size = this.selectedSize();
const selectedOptions = this.selectedOptions();
return details.find(detail => return details.find(detail =>
(!colour || (detail.colour || detail.color) === colour) && (!colour || (detail.colour || detail.color) === colour) &&
(!size || detail.size === size) (!size || detail.size === size) &&
Object.entries(selectedOptions)
.filter(([key]) => key !== 'colour' && key !== 'size')
.every(([key, value]) => {
const dynamicValue = (detail as any)[key];
return !value || dynamicValue == null || String(dynamicValue) === value;
})
) ?? null; ) ?? null;
}); });
readonly variantOptionGroups = computed<VariantOptionGroup[]>(() => {
const current = this.product();
if (!current) {
return [];
}
if (current.variantOptions?.length) {
return current.variantOptions.map(group => ({
key: group.key,
label: group.label || group.key,
options: group.options.map(option => ({
value: option.value,
label: option.label || option.value,
}))
}));
}
const groups: VariantOptionGroup[] = [];
const colours = this.availableColours();
const sizes = this.availableSizes();
if (colours.length > 0) {
groups.push({ key: 'colour', label: 'itemDetail.colour', options: colours.map(value => ({ value })) });
}
if (sizes.length > 0) {
groups.push({ key: 'size', label: 'itemDetail.size', options: sizes.map(value => ({ value })) });
}
return groups;
});
readonly isInWishlist = computed(() => {
const current = this.product();
return current ? this.uxFacade.isInWishlist(current.itemID) : false;
});
readonly isInCompare = computed(() => {
const current = this.product();
return current ? this.uxFacade.isInCompare(current.itemID) : false;
});
readonly effectivePrice = computed(() => this.selectedDetail()?.price ?? this.product()?.price ?? 0); readonly effectivePrice = computed(() => this.selectedDetail()?.price ?? this.product()?.price ?? 0);
readonly effectiveCurrency = computed(() => this.selectedDetail()?.currency ?? this.product()?.currency ?? ''); readonly effectiveCurrency = computed(() => this.selectedDetail()?.currency ?? this.product()?.currency ?? '');
readonly effectiveRemaining = computed(() => this.selectedDetail()?.remaining ?? this.product()?.quantity ?? null); readonly effectiveRemaining = computed(() => this.selectedDetail()?.remaining ?? this.product()?.quantity ?? null);
@@ -167,7 +237,9 @@ export class ProductDetailsContainerComponent {
this.missing.set(false); this.missing.set(false);
this.product.set(null); this.product.set(null);
this.relatedProducts.set([]); this.relatedProducts.set([]);
this.relatedCollections.set([]);
this.selectedPhotoIndex.set(0); this.selectedPhotoIndex.set(0);
this.selectedOptions.set({});
this.ratingSummary.set(null); this.ratingSummary.set(null);
this.reviewsResult.set(null); this.reviewsResult.set(null);
this.questionsResult.set(null); this.questionsResult.set(null);
@@ -200,6 +272,7 @@ export class ProductDetailsContainerComponent {
selectColour(colour: string): void { selectColour(colour: string): void {
this.selectedColour.set(colour); this.selectedColour.set(colour);
this.selectedOptions.update(state => ({ ...state, colour }));
const sizes = this.availableSizes(); const sizes = this.availableSizes();
if (sizes.length && this.selectedSize() && !sizes.includes(this.selectedSize()!)) { if (sizes.length && this.selectedSize() && !sizes.includes(this.selectedSize()!)) {
this.selectedSize.set(sizes[0]); this.selectedSize.set(sizes[0]);
@@ -208,6 +281,20 @@ export class ProductDetailsContainerComponent {
selectSize(size: string): void { selectSize(size: string): void {
this.selectedSize.set(size); this.selectedSize.set(size);
this.selectedOptions.update(state => ({ ...state, size }));
}
selectVariantOption(payload: { groupKey: string; value: string }): void {
this.selectedOptions.update(state => ({ ...state, [payload.groupKey]: payload.value }));
if (payload.groupKey === 'colour') {
this.selectColour(payload.value);
return;
}
if (payload.groupKey === 'size') {
this.selectSize(payload.value);
}
} }
addToCart(): void { addToCart(): void {
@@ -221,6 +308,49 @@ export class ProductDetailsContainerComponent {
}); });
} }
buyNow(): void {
this.addToCart();
this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
}
toggleWishlist(): void {
const current = this.product();
if (!current) {
return;
}
this.uxFacade.toggleWishlist(current);
}
toggleCompare(): void {
const current = this.product();
if (!current) {
return;
}
if (this.uxFacade.isInCompare(current.itemID)) {
this.uxFacade.removeFromCompare(current.itemID);
return;
}
const maxItems = Math.max(1, this.userExperienceConfig().compare.maxItems);
this.uxFacade.addToCompare(current, maxItems);
}
async shareProduct(): Promise<void> {
const current = this.product();
if (!current || typeof window === 'undefined') {
return;
}
const url = `${window.location.origin}/${this.languageService.currentLanguage()}/product/${current.itemID}`;
await this.shareService.shareProduct(current, url);
}
notifyMe(): void {
this.toggleWishlist();
}
addRelatedToCart(payload: { product: Product; event: Event }): void { addRelatedToCart(payload: { product: Product; event: Event }): void {
payload.event.preventDefault(); payload.event.preventDefault();
payload.event.stopPropagation(); payload.event.stopPropagation();
@@ -244,7 +374,7 @@ export class ProductDetailsContainerComponent {
this.loadProduct(productId); this.loadProduct(productId);
} }
loadReviews(page: number): void { loadReviews(page: number, append = false): void {
const current = this.product(); const current = this.product();
if (!current || this.productPageConfigState().reviews.enabled === false) { if (!current || this.productPageConfigState().reviews.enabled === false) {
return; return;
@@ -258,7 +388,18 @@ export class ProductDetailsContainerComponent {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: result => { next: result => {
if (append && this.reviewsMode() === 'load-more') {
const previous = this.reviewsResult();
const mergedItems = [...(previous?.items ?? []), ...result.items]
.filter((item, index, source) => source.findIndex(entry => entry.id === item.id) === index);
this.reviewsResult.set({
...result,
items: mergedItems,
});
} else {
this.reviewsResult.set(result); this.reviewsResult.set(result);
}
this.reviewsLoading.set(false); this.reviewsLoading.set(false);
}, },
error: () => { error: () => {
@@ -305,7 +446,7 @@ export class ProductDetailsContainerComponent {
next: () => { next: () => {
this.reviewSubmitting.set(false); this.reviewSubmitting.set(false);
this.loadRating(current.itemID); this.loadRating(current.itemID);
this.loadReviews(this.reviewsResult()?.page ?? 1); this.loadReviews(1, false);
}, },
error: () => this.reviewSubmitting.set(false) error: () => this.reviewSubmitting.set(false)
}); });
@@ -338,16 +479,55 @@ export class ProductDetailsContainerComponent {
if (details?.length) { if (details?.length) {
this.selectedColour.set(details[0].colour || details[0].color || null); this.selectedColour.set(details[0].colour || details[0].color || null);
this.selectedSize.set(details[0].size && details[0].size.toLowerCase() !== 'default' ? details[0].size : null); this.selectedSize.set(details[0].size && details[0].size.toLowerCase() !== 'default' ? details[0].size : null);
this.selectedOptions.set({
colour: details[0].colour || details[0].color || '',
size: details[0].size && details[0].size.toLowerCase() !== 'default' ? details[0].size : ''
});
return; return;
} }
this.selectedColour.set(product.colour ?? null); this.selectedColour.set(product.colour ?? null);
this.selectedSize.set(product.size && product.size.toLowerCase() !== 'default' ? product.size : null); this.selectedSize.set(product.size && product.size.toLowerCase() !== 'default' ? product.size : null);
this.selectedOptions.set({
colour: product.colour ?? '',
size: product.size && product.size.toLowerCase() !== 'default' ? product.size : ''
});
} }
private loadRelatedProducts(product: Product): void { private loadRelatedProducts(product: Product): void {
if (this.productPageConfigState().relatedProducts.enabled === false) { if (this.productPageConfigState().relatedProducts.enabled === false) {
this.relatedProducts.set([]); this.relatedProducts.set([]);
this.relatedCollections.set([]);
return;
}
if (product.relatedCollections?.length) {
const language = this.languageService.currentLanguage();
const requests = product.relatedCollections.map(collection =>
forkJoin(
(collection.products ?? [])
.filter(productID => productID !== product.itemID)
.map(productID => this.productFacade.getProduct(productID).pipe(catchError(() => of(null))))
).pipe(
catchError(() => of([] as Array<Product | null>))
)
);
forkJoin(requests)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
const collections = result.map((products, index) => {
const config = product.relatedCollections![index];
return {
id: config.id,
title: config.titles?.[language] || config.title || this.translate.t('itemDetail.relatedProducts'),
products: products.filter((item): item is Product => !!item)
} as RelatedCollectionViewModel;
}).filter(collection => collection.products.length > 0);
this.relatedCollections.set(collections);
this.relatedProducts.set(collections[0]?.products ?? []);
});
return; return;
} }
@@ -357,8 +537,19 @@ export class ProductDetailsContainerComponent {
count: 8, count: 8,
}).pipe(takeUntilDestroyed(this.destroyRef)) }).pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: result => this.relatedProducts.set(result.items.filter(item => item.itemID !== product.itemID)), next: result => {
error: () => this.relatedProducts.set([]), const products = result.items.filter(item => item.itemID !== product.itemID);
this.relatedProducts.set(products);
this.relatedCollections.set([{
id: 'default-related',
title: this.translate.t('itemDetail.relatedProducts'),
products
}]);
},
error: () => {
this.relatedProducts.set([]);
this.relatedCollections.set([]);
},
}); });
} }
@@ -368,7 +559,7 @@ export class ProductDetailsContainerComponent {
} }
if (this.productPageConfigState().reviews.enabled !== false) { if (this.productPageConfigState().reviews.enabled !== false) {
this.loadReviews(1); this.loadReviews(1, false);
} }
if (this.productPageConfigState().questions.enabled !== false) { if (this.productPageConfigState().questions.enabled !== false) {
@@ -466,6 +657,10 @@ export class ProductDetailsContainerComponent {
relatedProducts: { relatedProducts: {
...DEFAULT_PRODUCT_PAGE_CONFIG.relatedProducts, ...DEFAULT_PRODUCT_PAGE_CONFIG.relatedProducts,
...(bootstrapConfig.relatedProducts ?? {}) ...(bootstrapConfig.relatedProducts ?? {})
},
actions: {
...DEFAULT_PRODUCT_PAGE_CONFIG.actions,
...(bootstrapConfig.actions ?? {})
} }
}; };
} }

View File

@@ -1,9 +1,13 @@
<section class="question-list section"> <section class="question-list section">
@if (allowSubmission) {
<button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())"> <button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())">
@if (askFormVisible()) { {{ 'productDetails.hideQuestionForm' | translate }} } @else { {{ 'productDetails.askQuestion' | translate }} } @if (askFormVisible()) { {{ 'productDetails.hideQuestionForm' | translate }} } @else { {{ 'productDetails.askQuestion' | translate }} }
</button> </button>
} @else {
<p class="submission-disabled">{{ 'productDetails.questionsSubmissionFuture' | translate }}</p>
}
@if (askFormVisible()) { @if (allowSubmission && askFormVisible()) {
<app-question-form [submitting]="submitting" (submitQuestion)="onSubmit($event)" /> <app-question-form [submitting]="submitting" (submitQuestion)="onSubmit($event)" />
} }

View File

@@ -31,6 +31,12 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.submission-disabled {
margin: 0;
color: var(--text-secondary);
font-weight: 600;
}
.pager { .pager {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -16,6 +16,7 @@ export class QuestionListComponent {
@Input() result: EngagementListResult<Question> | null = null; @Input() result: EngagementListResult<Question> | null = null;
@Input() loading = false; @Input() loading = false;
@Input() submitting = false; @Input() submitting = false;
@Input() allowSubmission = true;
@Output() pageChange = new EventEmitter<number>(); @Output() pageChange = new EventEmitter<number>();
@Output() submitQuestion = new EventEmitter<SubmitQuestionInput>(); @Output() submitQuestion = new EventEmitter<SubmitQuestionInput>();

View File

@@ -1,17 +1,27 @@
<form class="review-form card" (ngSubmit)="onSubmit()"> <form class="review-form card" (ngSubmit)="onSubmit()">
<h3>{{ 'productDetails.reviewFormTitle' | translate }}</h3> <h3>{{ 'productDetails.reviewFormTitle' | translate }}</h3>
@if (submittedSuccessfully) {
<p class="success">{{ 'productDetails.reviewSuccess' | translate }}</p>
}
<label>{{ 'productDetails.reviewRatingLabel' | translate }}</label> <label>{{ 'productDetails.reviewRatingLabel' | translate }}</label>
<app-star-selector [(rating)]="rating" /> <app-star-selector [rating]="rating" (ratingChange)="updateRating($event)" />
@if (showRatingError) {
<p class="error">{{ 'productDetails.reviewRatingRequired' | translate }}</p>
}
<label for="review-title">{{ 'productDetails.reviewTitleLabel' | translate }}</label> <label for="review-title">{{ 'productDetails.reviewTitleLabel' | translate }}</label>
<input id="review-title" name="title" [(ngModel)]="title" [placeholder]="'productDetails.reviewTitlePlaceholder' | translate" maxlength="120" /> <input id="review-title" name="title" [(ngModel)]="title" [placeholder]="'productDetails.reviewTitlePlaceholder' | translate" maxlength="120" (input)="hideSuccess()" />
<label for="review-text">{{ 'productDetails.reviewTextLabel' | translate }}</label> <label for="review-text">{{ 'productDetails.reviewTextLabel' | translate }}</label>
<textarea id="review-text" name="text" [(ngModel)]="text" rows="4" [placeholder]="'productDetails.reviewTextPlaceholder' | translate"></textarea> <textarea id="review-text" name="text" [(ngModel)]="text" rows="4" [placeholder]="'productDetails.reviewTextPlaceholder' | translate" (input)="hideSuccess()"></textarea>
@if (showTextError) {
<p class="error">{{ 'productDetails.reviewTextRequired' | translate }}</p>
}
<label class="check"> <label class="check">
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" /> <input type="checkbox" name="anonymous" [(ngModel)]="anonymous" (change)="hideSuccess()" />
<span>{{ 'productDetails.reviewAnonymous' | translate }}</span> <span>{{ 'productDetails.reviewAnonymous' | translate }}</span>
</label> </label>

View File

@@ -41,6 +41,21 @@ textarea {
font-size: 0.85rem; font-size: 0.85rem;
} }
.success,
.error {
margin: 0;
font-size: 0.85rem;
}
.success {
color: #166534;
font-weight: 700;
}
.error {
color: #991b1b;
}
button { button {
justify-self: start; justify-self: start;
min-height: 40px; min-height: 40px;

View File

@@ -20,8 +20,11 @@ export class ReviewFormComponent {
title = ''; title = '';
text = ''; text = '';
anonymous = false; anonymous = false;
attemptedSubmit = false;
submittedSuccessfully = false;
onSubmit(): void { onSubmit(): void {
this.attemptedSubmit = true;
const payload: SubmitReviewInput = { const payload: SubmitReviewInput = {
rating: this.rating, rating: this.rating,
title: this.title, title: this.title,
@@ -34,13 +37,32 @@ export class ReviewFormComponent {
} }
this.submitReview.emit(payload); this.submitReview.emit(payload);
this.submittedSuccessfully = true;
this.reset(); this.reset();
} }
updateRating(value: number): void {
this.rating = value;
this.submittedSuccessfully = false;
}
hideSuccess(): void {
this.submittedSuccessfully = false;
}
get showRatingError(): boolean {
return this.attemptedSubmit && !this.rating;
}
get showTextError(): boolean {
return this.attemptedSubmit && !this.text.trim();
}
private reset(): void { private reset(): void {
this.rating = 0; this.rating = 0;
this.title = ''; this.title = '';
this.text = ''; this.text = '';
this.anonymous = false; this.anonymous = false;
this.attemptedSubmit = false;
} }
} }

View File

@@ -20,11 +20,20 @@
<p class="empty">{{ 'productDetails.reviewsEmpty' | translate }}</p> <p class="empty">{{ 'productDetails.reviewsEmpty' | translate }}</p>
} }
@if (result && totalPages > 1) { @if (result && totalPages > 1 && mode === 'pages') {
<div class="pager"> <div class="pager">
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">{{ 'productDetails.previous' | translate }}</button> <button type="button" (click)="previousPage()" [disabled]="result.page <= 1">{{ 'productDetails.previous' | translate }}</button>
<span>{{ 'productDetails.pageOf' | translate:{ page: result.page, total: totalPages } }}</span> <span>{{ 'productDetails.pageOf' | translate:{ page: result.page, total: totalPages } }}</span>
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">{{ 'productDetails.next' | translate }}</button> <button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">{{ 'productDetails.next' | translate }}</button>
</div> </div>
} }
@if (result && mode === 'load-more' && result.items.length < result.total) {
<div class="load-more-wrap">
<button type="button" class="load-more" (click)="onLoadMore()" [disabled]="loading">
{{ 'productDetails.loadMore' | translate }}
</button>
<small>{{ 'productDetails.showingOf' | translate:{ shown: result.items.length, total: result.total } }}</small>
</div>
}
</section> </section>

View File

@@ -35,6 +35,27 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.load-more-wrap {
display: grid;
justify-items: center;
gap: 8px;
}
.load-more {
min-height: 38px;
border: 1px solid var(--primary-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--primary-color);
padding: 0 16px;
font-weight: 700;
cursor: pointer;
}
.load-more-wrap small {
color: var(--text-secondary);
}
.skeleton-list { .skeleton-list {
display: grid; display: grid;
gap: 12px; gap: 12px;

View File

@@ -20,8 +20,10 @@ export class ReviewListComponent {
@Input() loading = false; @Input() loading = false;
@Input() submitting = false; @Input() submitting = false;
@Input() showSummary = true; @Input() showSummary = true;
@Input() mode: 'pages' | 'load-more' = 'pages';
@Output() pageChange = new EventEmitter<number>(); @Output() pageChange = new EventEmitter<number>();
@Output() loadMore = new EventEmitter<number>();
@Output() submitReview = new EventEmitter<SubmitReviewInput>(); @Output() submitReview = new EventEmitter<SubmitReviewInput>();
get totalPages(): number { get totalPages(): number {
@@ -45,4 +47,12 @@ export class ReviewListComponent {
this.pageChange.emit(this.result.page + 1); this.pageChange.emit(this.result.page + 1);
} }
onLoadMore(): void {
if (!this.result || this.result.items.length >= this.result.total || this.loading) {
return;
}
this.loadMore.emit((this.result.page ?? 1) + 1);
}
} }

View File

@@ -267,8 +267,27 @@ export const en: Translations = {
reviewTextPlaceholder: 'Describe your experience', reviewTextPlaceholder: 'Describe your experience',
reviewAnonymous: 'Submit anonymously', reviewAnonymous: 'Submit anonymously',
reviewUploadPlaceholder: 'Photo upload is planned for a future sprint.', reviewUploadPlaceholder: 'Photo upload is planned for a future sprint.',
reviewRatingRequired: 'Please select a rating.',
reviewTextRequired: 'Please add your review text.',
reviewSuccess: 'Review submitted successfully.',
submitReview: 'Submit review', submitReview: 'Submit review',
submitting: 'Submitting...', submitting: 'Submitting...',
loadMore: 'Load more',
showingOf: 'Showing {{shown}} of {{total}}',
questionsSubmissionFuture: 'Question submission can be enabled later without component changes.',
specificationsGeneral: 'General',
actionsAria: 'Product actions',
buyNow: 'Buy now',
wishlist: 'Wishlist',
compare: 'Compare',
share: 'Share',
notifyMe: 'Notify me',
zoom: 'Zoom',
fullscreen: 'Fullscreen',
pdfDocument: 'Product PDF document',
manualDocument: 'Product manual',
warrantyDocument: 'Warranty document',
openDocument: 'Open document',
specificationsEmpty: 'No specifications provided yet.', specificationsEmpty: 'No specifications provided yet.',
warrantyTitle: 'Warranty and returns', warrantyTitle: 'Warranty and returns',
warrantyItem1: 'Warranty terms are provided by the seller and local law.', warrantyItem1: 'Warranty terms are provided by the seller and local law.',

View File

@@ -267,8 +267,27 @@ export const hy: Translations = {
reviewTextPlaceholder: 'Նկարագրեք ձեր փորձը', reviewTextPlaceholder: 'Նկարագրեք ձեր փորձը',
reviewAnonymous: 'Ուղարկել անանուն', reviewAnonymous: 'Ուղարկել անանուն',
reviewUploadPlaceholder: 'Լուսանկար վերբեռնելը նախատեսված է հաջորդ սպրինտում։', reviewUploadPlaceholder: 'Լուսանկար վերբեռնելը նախատեսված է հաջորդ սպրինտում։',
reviewRatingRequired: 'Խնդրում ենք ընտրել գնահատական։',
reviewTextRequired: 'Խնդրում ենք գրել կարծիք։',
reviewSuccess: 'Կարծիքը հաջողությամբ ուղարկվեց։',
submitReview: 'Ուղարկել կարծիքը', submitReview: 'Ուղարկել կարծիքը',
submitting: 'Ուղարկվում է...', submitting: 'Ուղարկվում է...',
loadMore: 'Բեռնել ավելին',
showingOf: 'Ցուցադրվում է {{shown}} / {{total}}',
questionsSubmissionFuture: 'Հարցի ուղարկումը կարելի է միացնել ավելի ուշ՝ առանց բաղադրիչի փոփոխության։',
specificationsGeneral: 'Ընդհանուր',
actionsAria: 'Ապրանքի գործողություններ',
buyNow: 'Գնել հիմա',
wishlist: 'Նախընտրածներ',
compare: 'Համեմատել',
share: 'Կիսվել',
notifyMe: 'Ծանուցել ինձ',
zoom: 'Մեծացնել',
fullscreen: 'Ամբողջ էկրան',
pdfDocument: 'Ապրանքի PDF փաստաթուղթ',
manualDocument: 'Ապրանքի ուղեցույց',
warrantyDocument: 'Երաշխիքի փաստաթուղթ',
openDocument: 'Բացել փաստաթուղթը',
specificationsEmpty: 'Բնութագրերը դեռ հասանելի չեն։', specificationsEmpty: 'Բնութագրերը դեռ հասանելի չեն։',
warrantyTitle: 'Երաշխիք և վերադարձ', warrantyTitle: 'Երաշխիք և վերադարձ',
warrantyItem1: 'Երաշխիքի պայմանները սահմանվում են վաճառողի և տեղական օրենքներով։', warrantyItem1: 'Երաշխիքի պայմանները սահմանվում են վաճառողի և տեղական օրենքներով։',

View File

@@ -267,8 +267,27 @@ export const ru: Translations = {
reviewTextPlaceholder: 'Опишите ваш опыт использования', reviewTextPlaceholder: 'Опишите ваш опыт использования',
reviewAnonymous: 'Отправить анонимно', reviewAnonymous: 'Отправить анонимно',
reviewUploadPlaceholder: 'Загрузка фото запланирована в следующем спринте.', reviewUploadPlaceholder: 'Загрузка фото запланирована в следующем спринте.',
reviewRatingRequired: 'Выберите оценку.',
reviewTextRequired: 'Добавьте текст отзыва.',
reviewSuccess: 'Отзыв успешно отправлен.',
submitReview: 'Отправить отзыв', submitReview: 'Отправить отзыв',
submitting: 'Отправка...', submitting: 'Отправка...',
loadMore: 'Показать еще',
showingOf: 'Показано {{shown}} из {{total}}',
questionsSubmissionFuture: 'Отправку вопросов можно включить позже без изменения компонента.',
specificationsGeneral: 'Общие',
actionsAria: 'Действия с товаром',
buyNow: 'Купить сейчас',
wishlist: 'В избранное',
compare: 'Сравнить',
share: 'Поделиться',
notifyMe: 'Сообщить о наличии',
zoom: 'Увеличить',
fullscreen: 'Полный экран',
pdfDocument: 'PDF документ товара',
manualDocument: 'Инструкция товара',
warrantyDocument: 'Гарантийный документ',
openDocument: 'Открыть документ',
specificationsEmpty: 'Характеристики пока не указаны.', specificationsEmpty: 'Характеристики пока не указаны.',
warrantyTitle: 'Гарантия и возврат', warrantyTitle: 'Гарантия и возврат',
warrantyItem1: 'Условия гарантии определяются продавцом и местным законодательством.', warrantyItem1: 'Условия гарантии определяются продавцом и местным законодательством.',

View File

@@ -265,8 +265,27 @@ export interface Translations {
reviewTextPlaceholder: string; reviewTextPlaceholder: string;
reviewAnonymous: string; reviewAnonymous: string;
reviewUploadPlaceholder: string; reviewUploadPlaceholder: string;
reviewRatingRequired: string;
reviewTextRequired: string;
reviewSuccess: string;
submitReview: string; submitReview: string;
submitting: string; submitting: string;
loadMore: string;
showingOf: string;
questionsSubmissionFuture: string;
specificationsGeneral: string;
actionsAria: string;
buyNow: string;
wishlist: string;
compare: string;
share: string;
notifyMe: string;
zoom: string;
fullscreen: string;
pdfDocument: string;
manualDocument: string;
warrantyDocument: string;
openDocument: string;
specificationsEmpty: string; specificationsEmpty: string;
warrantyTitle: string; warrantyTitle: string;
warrantyItem1: string; warrantyItem1: string;

View File

@@ -5,6 +5,18 @@ interface Photo {
type?: string; type?: string;
} }
export type ProductMediaType = 'image' | 'video' | 'pdf' | 'manual' | 'warranty';
export interface ProductMedia {
id?: string;
type: ProductMediaType;
url: string;
thumbnailUrl?: string;
alt?: string;
title?: string;
labels?: Record<string, string>;
}
export interface DescriptionField { export interface DescriptionField {
key: string; key: string;
value: string; value: string;
@@ -59,6 +71,47 @@ interface ItemAttribute {
value: string; value: string;
} }
interface LocalizedValue {
[language: string]: string;
}
export interface ProductSpecificationField {
key: string;
value: string;
label?: string;
labels?: LocalizedValue;
unit?: string;
}
export interface ProductSpecificationGroup {
id?: string;
key: string;
label?: string;
labels?: LocalizedValue;
attributes: ProductSpecificationField[];
}
export interface ProductVariantOption {
value: string;
label?: string;
labels?: LocalizedValue;
available?: boolean;
}
export interface ProductVariantOptionGroup {
key: string;
label?: string;
labels?: LocalizedValue;
options: ProductVariantOption[];
}
export interface RelatedProductCollection {
id: string;
title: string;
titles?: LocalizedValue;
products: number[];
}
export interface DeliveryOption { export interface DeliveryOption {
deliveryPrice: number; deliveryPrice: number;
deliveryPlace: string; deliveryPlace: string;
@@ -104,6 +157,10 @@ export interface Item {
names?: ItemName[]; names?: ItemName[];
descriptions?: ItemDescription[]; descriptions?: ItemDescription[];
attributes?: ItemAttribute[]; attributes?: ItemAttribute[];
media?: ProductMedia[];
specificationGroups?: ProductSpecificationGroup[];
variantOptions?: ProductVariantOptionGroup[];
relatedCollections?: RelatedProductCollection[];
// BackOffice API fields // BackOffice API fields
id?: string; id?: string;

View File

@@ -98,6 +98,15 @@ export class ApiService {
return c.startsWith('0x') ? '#' + c.slice(2) : c; 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( private normalizeDeliveryData(
raw: any, raw: any,
legacyDeliveryPrice?: number legacyDeliveryPrice?: number
@@ -256,6 +265,29 @@ export class ApiService {
item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u)) item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u))
|| item.photos?.map((p: any) => p.url) || []; || 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 // Map backOffice description (key-value array) → legacy description string
if (Array.isArray(raw.description)) { if (Array.isArray(raw.description)) {
item.descriptionFields = raw.description; item.descriptionFields = raw.description;
@@ -287,6 +319,51 @@ export class ApiService {
// Preserve attributes from backend // Preserve attributes from backend
item.attributes = raw.attributes || []; 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) // Preserve colour & size (only if not already set from itemDetails)
if (!item.colour) item.colour = this.normalizeColor(raw.colour || ''); if (!item.colour) item.colour = this.normalizeColor(raw.colour || '');
if (!item.size) item.size = raw.size || ''; if (!item.size) item.size = raw.size || '';

View File

@@ -7,10 +7,12 @@ export interface ProductRatingConfig extends ProductSectionConfig {}
export interface ProductReviewsConfig extends ProductSectionConfig { export interface ProductReviewsConfig extends ProductSectionConfig {
pageSize?: number; pageSize?: number;
showSummary?: boolean; showSummary?: boolean;
mode?: 'pages' | 'load-more';
} }
export interface ProductQuestionsConfig extends ProductSectionConfig { export interface ProductQuestionsConfig extends ProductSectionConfig {
pageSize?: number; pageSize?: number;
allowSubmission?: boolean;
} }
export interface ProductTabsConfig extends ProductSectionConfig { export interface ProductTabsConfig extends ProductSectionConfig {
@@ -19,12 +21,22 @@ export interface ProductTabsConfig extends ProductSectionConfig {
export interface ProductRelatedConfig extends ProductSectionConfig {} export interface ProductRelatedConfig extends ProductSectionConfig {}
export interface ProductActionsConfig extends ProductSectionConfig {
addToCart?: boolean;
buyNow?: boolean;
wishlist?: boolean;
compare?: boolean;
share?: boolean;
notifyMe?: boolean;
}
export interface ProductPageConfig { export interface ProductPageConfig {
rating?: ProductRatingConfig; rating?: ProductRatingConfig;
reviews?: ProductReviewsConfig; reviews?: ProductReviewsConfig;
questions?: ProductQuestionsConfig; questions?: ProductQuestionsConfig;
tabs?: ProductTabsConfig; tabs?: ProductTabsConfig;
relatedProducts?: ProductRelatedConfig; relatedProducts?: ProductRelatedConfig;
actions?: ProductActionsConfig;
} }
export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = { export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
@@ -34,11 +46,13 @@ export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
reviews: { reviews: {
enabled: true, enabled: true,
pageSize: 5, pageSize: 5,
showSummary: true showSummary: true,
mode: 'pages'
}, },
questions: { questions: {
enabled: true, enabled: true,
pageSize: 5 pageSize: 5,
allowSubmission: true
}, },
tabs: { tabs: {
enabled: true, enabled: true,
@@ -46,5 +60,14 @@ export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
}, },
relatedProducts: { relatedProducts: {
enabled: true enabled: true
},
actions: {
enabled: true,
addToCart: true,
buyNow: true,
wishlist: true,
compare: true,
share: true,
notifyMe: true
} }
}; };