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:
@@ -36,3 +36,14 @@ export interface RelatedProductsQuery {
|
||||
categoryID?: number;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface RelatedProductCollection {
|
||||
id: string;
|
||||
title: string;
|
||||
titles?: Record<string, string>;
|
||||
productIDs: number[];
|
||||
}
|
||||
|
||||
export interface ProductVariantSelection {
|
||||
[groupKey: string]: string;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -1,9 +1,38 @@
|
||||
<section class="product-gallery" [attr.aria-label]="'productDetails.mediaAria' | translate">
|
||||
<div class="product-gallery-main">
|
||||
@if (media[selectedIndex]?.video) {
|
||||
<video [src]="media[selectedIndex].url" controls></video>
|
||||
} @else {
|
||||
<img [src]="media[selectedIndex].url" [alt]="product.name" loading="eager" decoding="async" />
|
||||
<div class="product-gallery-main" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
|
||||
<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>
|
||||
}
|
||||
|
||||
@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>
|
||||
|
||||
@@ -11,10 +40,12 @@
|
||||
<div class="product-gallery-thumbs">
|
||||
@for (item of media; track $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>
|
||||
} @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>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
}
|
||||
|
||||
.product-gallery-main {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid #d3dad9;
|
||||
border-radius: 13px;
|
||||
@@ -16,10 +17,58 @@
|
||||
transition: box-shadow 0.24s ease, border-color 0.24s ease;
|
||||
|
||||
img,
|
||||
video {
|
||||
video,
|
||||
object {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.product-gallery-thumbs {
|
||||
grid-template-columns: repeat(auto-fill, minmax(58px, 1fr));
|
||||
|
||||
@@ -3,6 +3,16 @@ import { Product } from '../../../../../core/products/models/product-domain.mode
|
||||
import { getMainImage } from '../../../../../utils/item.utils';
|
||||
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({
|
||||
selector: 'app-product-gallery',
|
||||
standalone: true,
|
||||
@@ -16,14 +26,82 @@ export class ProductGalleryComponent {
|
||||
@Input() selectedIndex = 0;
|
||||
|
||||
@Output() selectedIndexChange = new EventEmitter<number>();
|
||||
@Output() zoomRequested = new EventEmitter<number>();
|
||||
@Output() fullscreenRequested = new EventEmitter<number>();
|
||||
|
||||
readonly getMainImage = getMainImage;
|
||||
|
||||
get media() {
|
||||
return this.product.photos?.length ? this.product.photos : [{ url: this.getMainImage(this.product) }];
|
||||
private touchStartX = 0;
|
||||
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 {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="button" class="add-to-cart" (click)="addToCart.emit()">
|
||||
{{ 'itemDetail.addToCart' | translate }}
|
||||
</button>
|
||||
@if (showAddToCart) {
|
||||
<button type="button" class="add-to-cart" (click)="addToCart.emit()">
|
||||
{{ 'itemDetail.addToCart' | translate }}
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ProductInformationComponent {
|
||||
@Input() currency = '';
|
||||
@Input() remaining: number | null = null;
|
||||
@Input() stockStatus = 'high';
|
||||
@Input() showAddToCart = true;
|
||||
|
||||
@Output() addToCart = new EventEmitter<void>();
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<section class="product-specifications card section">
|
||||
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
|
||||
|
||||
@if (descriptionFields.length > 0) {
|
||||
<dl>
|
||||
@for (field of descriptionFields; track field.key) {
|
||||
<div>
|
||||
<dt>{{ field.key }}</dt>
|
||||
<dd>{{ field.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
@if (groups.length > 0) {
|
||||
@for (group of groups; track group.key) {
|
||||
<section class="spec-group">
|
||||
<h4>{{ group.label | translate }}</h4>
|
||||
<dl>
|
||||
@for (field of group.fields; track field.key) {
|
||||
<div>
|
||||
<dt>{{ field.label | translate }}</dt>
|
||||
<dd>{{ field.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
} @else {
|
||||
<p>{{ 'productDetails.specificationsEmpty' | translate }}</p>
|
||||
}
|
||||
|
||||
@@ -7,6 +7,18 @@ h3 {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.spec-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
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({
|
||||
selector: 'app-product-specifications',
|
||||
@@ -13,7 +26,47 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
export class ProductSpecificationsComponent {
|
||||
@Input({ required: true }) product!: Product;
|
||||
|
||||
get descriptionFields() {
|
||||
return this.product.descriptionFields ?? [];
|
||||
constructor(private readonly languageService: LanguageService) {}
|
||||
|
||||
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,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
@if (products.length > 0) {
|
||||
<section class="related-products">
|
||||
<h2>{{ 'itemDetail.relatedProducts' | translate }}</h2>
|
||||
<div class="related-grid">
|
||||
@for (product of products; track trackByItemId($index, product)) {
|
||||
<app-product-card
|
||||
[item]="product"
|
||||
[title]="productTitle(product)"
|
||||
appearance="compact"
|
||||
[addToCartLabel]="'itemDetail.addToCart' | translate"
|
||||
(selected)="productSelected.emit(product)"
|
||||
(addToCart)="addToCart.emit({ product, event: $event.event })"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@if (normalizedCollections.length > 0) {
|
||||
@for (collection of normalizedCollections; track collection.id) {
|
||||
<section class="related-products">
|
||||
<h2>{{ collection.title | translate }}</h2>
|
||||
<div class="related-grid">
|
||||
@for (product of collection.products; track trackByItemId($index, product)) {
|
||||
<app-product-card
|
||||
[item]="product"
|
||||
[title]="productTitle(product)"
|
||||
appearance="compact"
|
||||
[addToCartLabel]="'itemDetail.addToCart' | translate"
|
||||
(selected)="productSelected.emit(product)"
|
||||
(addToCart)="addToCart.emit({ product, event: $event.event })"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../../services/language.service';
|
||||
import { getTranslatedField, trackByItemId } from '../../../../../utils/item.utils';
|
||||
|
||||
export interface RelatedCollectionViewModel {
|
||||
id: string;
|
||||
title: string;
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-related-products',
|
||||
standalone: true,
|
||||
@@ -15,6 +21,7 @@ import { getTranslatedField, trackByItemId } from '../../../../../utils/item.uti
|
||||
})
|
||||
export class RelatedProductsComponent {
|
||||
@Input() products: Product[] = [];
|
||||
@Input() collections: RelatedCollectionViewModel[] = [];
|
||||
|
||||
@Output() productSelected = new EventEmitter<Product>();
|
||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||
@@ -23,6 +30,22 @@ export class RelatedProductsComponent {
|
||||
|
||||
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 {
|
||||
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
|
||||
}
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
@if (hasVariants) {
|
||||
<section class="variant-selector">
|
||||
@if (colours.length > 0) {
|
||||
@for (group of normalizedGroups; track group.key) {
|
||||
<div class="variant-group">
|
||||
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span>
|
||||
<span class="variant-label">{{ group.label | translate }}</span>
|
||||
<div class="variant-options">
|
||||
@for (colour of colours; track $index) {
|
||||
<button type="button" class="colour-swatch" [class.active]="selectedColour === colour" [style.background-color]="colour" [attr.aria-label]="colour" (click)="colourSelected.emit(colour)"></button>
|
||||
@for (option of group.options; track option.value) {
|
||||
@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>
|
||||
} @else if (fallbackColour) {
|
||||
}
|
||||
|
||||
@if (normalizedGroups.length === 0 && fallbackColour) {
|
||||
<div class="variant-group">
|
||||
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span>
|
||||
<span class="colour-swatch readonly" [style.background-color]="fallbackColour"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sizes.length > 0) {
|
||||
<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) {
|
||||
@if (normalizedGroups.length === 0 && visibleFallbackSize) {
|
||||
<div class="variant-group">
|
||||
<span class="variant-label">{{ 'itemDetail.size' | translate }}</span>
|
||||
<span class="size-chip readonly">{{ visibleFallbackSize }}</span>
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
export interface VariantOption {
|
||||
value: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface VariantOptionGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
options: VariantOption[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-variant-selector',
|
||||
standalone: true,
|
||||
@@ -10,6 +21,9 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProductVariantSelectorComponent {
|
||||
@Input() optionGroups: VariantOptionGroup[] = [];
|
||||
@Input() selectedOptions: Record<string, string> = {};
|
||||
|
||||
@Input() colours: string[] = [];
|
||||
@Input() sizes: string[] = [];
|
||||
@Input() selectedColour: string | null = null;
|
||||
@@ -17,11 +31,62 @@ export class ProductVariantSelectorComponent {
|
||||
@Input() fallbackColour: string | null = null;
|
||||
@Input() fallbackSize: string | null = null;
|
||||
|
||||
@Output() optionSelected = new EventEmitter<{ groupKey: string; value: string }>();
|
||||
@Output() colourSelected = new EventEmitter<string>();
|
||||
@Output() sizeSelected = new EventEmitter<string>();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -40,16 +40,32 @@
|
||||
[currency]="effectiveCurrency()"
|
||||
[remaining]="effectiveRemaining()"
|
||||
[stockStatus]="stockStatus()"
|
||||
[showAddToCart]="false"
|
||||
(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
|
||||
[optionGroups]="variantOptionGroups()"
|
||||
[selectedOptions]="selectedOptions()"
|
||||
[colours]="availableColours()"
|
||||
[sizes]="availableSizes()"
|
||||
[selectedColour]="selectedColour()"
|
||||
[selectedSize]="selectedSize()"
|
||||
[fallbackColour]="currentProduct.colour ?? null"
|
||||
[fallbackSize]="currentProduct.size ?? null"
|
||||
(optionSelected)="selectVariantOption($event)"
|
||||
(colourSelected)="selectColour($event)"
|
||||
(sizeSelected)="selectSize($event)"
|
||||
/>
|
||||
@@ -82,7 +98,9 @@
|
||||
[loading]="reviewsLoading() || ratingLoading()"
|
||||
[submitting]="reviewSubmitting()"
|
||||
[showSummary]="productPageConfigState().reviews.showSummary !== false"
|
||||
[mode]="reviewsMode()"
|
||||
(pageChange)="loadReviews($event)"
|
||||
(loadMore)="loadReviews($event, true)"
|
||||
(submitReview)="submitReview($event)" />
|
||||
}
|
||||
|
||||
@@ -91,6 +109,7 @@
|
||||
[result]="questionsResult()"
|
||||
[loading]="questionsLoading()"
|
||||
[submitting]="questionSubmitting()"
|
||||
[allowSubmission]="productPageConfigState().questions.allowSubmission !== false"
|
||||
(pageChange)="loadQuestions($event)"
|
||||
(submitQuestion)="submitQuestion($event)" />
|
||||
}
|
||||
@@ -122,7 +141,9 @@
|
||||
[loading]="reviewsLoading() || ratingLoading()"
|
||||
[submitting]="reviewSubmitting()"
|
||||
[showSummary]="productPageConfigState().reviews.showSummary !== false"
|
||||
[mode]="reviewsMode()"
|
||||
(pageChange)="loadReviews($event)"
|
||||
(loadMore)="loadReviews($event, true)"
|
||||
(submitReview)="submitReview($event)" />
|
||||
}
|
||||
|
||||
@@ -131,6 +152,7 @@
|
||||
[result]="questionsResult()"
|
||||
[loading]="questionsLoading()"
|
||||
[submitting]="questionSubmitting()"
|
||||
[allowSubmission]="productPageConfigState().questions.allowSubmission !== false"
|
||||
(pageChange)="loadQuestions($event)"
|
||||
(submitQuestion)="submitQuestion($event)" />
|
||||
}
|
||||
@@ -148,6 +170,7 @@
|
||||
@if (productPageConfigState().relatedProducts.enabled !== false) {
|
||||
<app-related-products
|
||||
[products]="relatedProducts()"
|
||||
[collections]="relatedCollections()"
|
||||
(productSelected)="selectRelatedProduct($event)"
|
||||
(addToCart)="addRelatedToCart($event)"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
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 { EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../../../core/products/models/product-engagement.model';
|
||||
import { ConfigService } from '../../../../core/config/config.service';
|
||||
@@ -13,14 +15,16 @@ import { CartService } from '../../../../services';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
|
||||
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 { ProductActionsComponent } from '../components/product-actions/product-actions.component';
|
||||
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
|
||||
import { ProductInformationComponent } from '../components/product-information/product-information.component';
|
||||
import { ProductDescriptionComponent } from '../components/product-description/product-description.component';
|
||||
import { ProductSpecificationsComponent } from '../components/product-specifications/product-specifications.component';
|
||||
import { ProductWarrantyComponent } from '../components/product-warranty/product-warranty.component';
|
||||
import { RelatedProductsComponent } from '../components/related-products/related-products.component';
|
||||
import { ProductVariantSelectorComponent } from '../components/variant-selector/variant-selector.component';
|
||||
import { RelatedCollectionViewModel, RelatedProductsComponent } from '../components/related-products/related-products.component';
|
||||
import { ProductVariantSelectorComponent, VariantOptionGroup } from '../components/variant-selector/variant-selector.component';
|
||||
import { ProductTabItem, ProductTabKey, ProductTabsComponent } from '../engagement/components/product-tabs/product-tabs.component';
|
||||
import { QuestionListComponent } from '../engagement/components/question-list/question-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,
|
||||
LangRoutePipe,
|
||||
TranslatePipe,
|
||||
ProductActionsComponent,
|
||||
ProductGalleryComponent,
|
||||
ProductInformationComponent,
|
||||
ProductVariantSelectorComponent,
|
||||
@@ -58,13 +63,16 @@ export class ProductDetailsContainerComponent {
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly shareService = inject(ProductShareService);
|
||||
|
||||
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
|
||||
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
||||
|
||||
readonly product = signal<Product | null>(null);
|
||||
readonly relatedProducts = signal<Product[]>([]);
|
||||
readonly relatedCollections = signal<RelatedCollectionViewModel[]>([]);
|
||||
readonly selectedPhotoIndex = signal(0);
|
||||
readonly selectedOptions = signal<Record<string, string>>({});
|
||||
readonly selectedColour = signal<string | null>(null);
|
||||
readonly selectedSize = signal<string | null>(null);
|
||||
readonly loading = signal(true);
|
||||
@@ -85,6 +93,19 @@ export class ProductDetailsContainerComponent {
|
||||
|
||||
readonly reviewsPageSize = computed(() => this.productPageConfigState().reviews.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 tabs = computed<ProductTabItem[]>(() => {
|
||||
@@ -122,12 +143,61 @@ export class ProductDetailsContainerComponent {
|
||||
if (!details?.length) return null;
|
||||
const colour = this.selectedColour();
|
||||
const size = this.selectedSize();
|
||||
const selectedOptions = this.selectedOptions();
|
||||
return details.find(detail =>
|
||||
(!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;
|
||||
});
|
||||
|
||||
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 effectiveCurrency = computed(() => this.selectedDetail()?.currency ?? this.product()?.currency ?? '');
|
||||
readonly effectiveRemaining = computed(() => this.selectedDetail()?.remaining ?? this.product()?.quantity ?? null);
|
||||
@@ -167,7 +237,9 @@ export class ProductDetailsContainerComponent {
|
||||
this.missing.set(false);
|
||||
this.product.set(null);
|
||||
this.relatedProducts.set([]);
|
||||
this.relatedCollections.set([]);
|
||||
this.selectedPhotoIndex.set(0);
|
||||
this.selectedOptions.set({});
|
||||
this.ratingSummary.set(null);
|
||||
this.reviewsResult.set(null);
|
||||
this.questionsResult.set(null);
|
||||
@@ -200,6 +272,7 @@ export class ProductDetailsContainerComponent {
|
||||
|
||||
selectColour(colour: string): void {
|
||||
this.selectedColour.set(colour);
|
||||
this.selectedOptions.update(state => ({ ...state, colour }));
|
||||
const sizes = this.availableSizes();
|
||||
if (sizes.length && this.selectedSize() && !sizes.includes(this.selectedSize()!)) {
|
||||
this.selectedSize.set(sizes[0]);
|
||||
@@ -208,6 +281,20 @@ export class ProductDetailsContainerComponent {
|
||||
|
||||
selectSize(size: string): void {
|
||||
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 {
|
||||
@@ -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 {
|
||||
payload.event.preventDefault();
|
||||
payload.event.stopPropagation();
|
||||
@@ -244,7 +374,7 @@ export class ProductDetailsContainerComponent {
|
||||
this.loadProduct(productId);
|
||||
}
|
||||
|
||||
loadReviews(page: number): void {
|
||||
loadReviews(page: number, append = false): void {
|
||||
const current = this.product();
|
||||
if (!current || this.productPageConfigState().reviews.enabled === false) {
|
||||
return;
|
||||
@@ -258,7 +388,18 @@ export class ProductDetailsContainerComponent {
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: result => {
|
||||
this.reviewsResult.set(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.reviewsLoading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
@@ -305,7 +446,7 @@ export class ProductDetailsContainerComponent {
|
||||
next: () => {
|
||||
this.reviewSubmitting.set(false);
|
||||
this.loadRating(current.itemID);
|
||||
this.loadReviews(this.reviewsResult()?.page ?? 1);
|
||||
this.loadReviews(1, false);
|
||||
},
|
||||
error: () => this.reviewSubmitting.set(false)
|
||||
});
|
||||
@@ -338,16 +479,55 @@ export class ProductDetailsContainerComponent {
|
||||
if (details?.length) {
|
||||
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.selectedOptions.set({
|
||||
colour: details[0].colour || details[0].color || '',
|
||||
size: details[0].size && details[0].size.toLowerCase() !== 'default' ? details[0].size : ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedColour.set(product.colour ?? 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 {
|
||||
if (this.productPageConfigState().relatedProducts.enabled === false) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -357,8 +537,19 @@ export class ProductDetailsContainerComponent {
|
||||
count: 8,
|
||||
}).pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: result => this.relatedProducts.set(result.items.filter(item => item.itemID !== product.itemID)),
|
||||
error: () => this.relatedProducts.set([]),
|
||||
next: result => {
|
||||
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) {
|
||||
this.loadReviews(1);
|
||||
this.loadReviews(1, false);
|
||||
}
|
||||
|
||||
if (this.productPageConfigState().questions.enabled !== false) {
|
||||
@@ -466,6 +657,10 @@ export class ProductDetailsContainerComponent {
|
||||
relatedProducts: {
|
||||
...DEFAULT_PRODUCT_PAGE_CONFIG.relatedProducts,
|
||||
...(bootstrapConfig.relatedProducts ?? {})
|
||||
},
|
||||
actions: {
|
||||
...DEFAULT_PRODUCT_PAGE_CONFIG.actions,
|
||||
...(bootstrapConfig.actions ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<section class="question-list section">
|
||||
<button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())">
|
||||
@if (askFormVisible()) { {{ 'productDetails.hideQuestionForm' | translate }} } @else { {{ 'productDetails.askQuestion' | translate }} }
|
||||
</button>
|
||||
@if (allowSubmission) {
|
||||
<button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())">
|
||||
@if (askFormVisible()) { {{ 'productDetails.hideQuestionForm' | translate }} } @else { {{ 'productDetails.askQuestion' | translate }} }
|
||||
</button>
|
||||
} @else {
|
||||
<p class="submission-disabled">{{ 'productDetails.questionsSubmissionFuture' | translate }}</p>
|
||||
}
|
||||
|
||||
@if (askFormVisible()) {
|
||||
@if (allowSubmission && askFormVisible()) {
|
||||
<app-question-form [submitting]="submitting" (submitQuestion)="onSubmit($event)" />
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.submission-disabled {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,6 +16,7 @@ export class QuestionListComponent {
|
||||
@Input() result: EngagementListResult<Question> | null = null;
|
||||
@Input() loading = false;
|
||||
@Input() submitting = false;
|
||||
@Input() allowSubmission = true;
|
||||
|
||||
@Output() pageChange = new EventEmitter<number>();
|
||||
@Output() submitQuestion = new EventEmitter<SubmitQuestionInput>();
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
<form class="review-form card" (ngSubmit)="onSubmit()">
|
||||
<h3>{{ 'productDetails.reviewFormTitle' | translate }}</h3>
|
||||
|
||||
@if (submittedSuccessfully) {
|
||||
<p class="success">{{ 'productDetails.reviewSuccess' | translate }}</p>
|
||||
}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" />
|
||||
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" (change)="hideSuccess()" />
|
||||
<span>{{ 'productDetails.reviewAnonymous' | translate }}</span>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -41,6 +41,21 @@ textarea {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.success,
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #166534;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
button {
|
||||
justify-self: start;
|
||||
min-height: 40px;
|
||||
|
||||
@@ -20,8 +20,11 @@ export class ReviewFormComponent {
|
||||
title = '';
|
||||
text = '';
|
||||
anonymous = false;
|
||||
attemptedSubmit = false;
|
||||
submittedSuccessfully = false;
|
||||
|
||||
onSubmit(): void {
|
||||
this.attemptedSubmit = true;
|
||||
const payload: SubmitReviewInput = {
|
||||
rating: this.rating,
|
||||
title: this.title,
|
||||
@@ -34,13 +37,32 @@ export class ReviewFormComponent {
|
||||
}
|
||||
|
||||
this.submitReview.emit(payload);
|
||||
this.submittedSuccessfully = true;
|
||||
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 {
|
||||
this.rating = 0;
|
||||
this.title = '';
|
||||
this.text = '';
|
||||
this.anonymous = false;
|
||||
this.attemptedSubmit = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,20 @@
|
||||
<p class="empty">{{ 'productDetails.reviewsEmpty' | translate }}</p>
|
||||
}
|
||||
|
||||
@if (result && totalPages > 1) {
|
||||
@if (result && totalPages > 1 && mode === 'pages') {
|
||||
<div class="pager">
|
||||
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">{{ 'productDetails.previous' | translate }}</button>
|
||||
<span>{{ 'productDetails.pageOf' | translate:{ page: result.page, total: totalPages } }}</span>
|
||||
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">{{ 'productDetails.next' | translate }}</button>
|
||||
</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>
|
||||
|
||||
@@ -35,6 +35,27 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
@@ -20,8 +20,10 @@ export class ReviewListComponent {
|
||||
@Input() loading = false;
|
||||
@Input() submitting = false;
|
||||
@Input() showSummary = true;
|
||||
@Input() mode: 'pages' | 'load-more' = 'pages';
|
||||
|
||||
@Output() pageChange = new EventEmitter<number>();
|
||||
@Output() loadMore = new EventEmitter<number>();
|
||||
@Output() submitReview = new EventEmitter<SubmitReviewInput>();
|
||||
|
||||
get totalPages(): number {
|
||||
@@ -45,4 +47,12 @@ export class ReviewListComponent {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,8 +267,27 @@ export const en: Translations = {
|
||||
reviewTextPlaceholder: 'Describe your experience',
|
||||
reviewAnonymous: 'Submit anonymously',
|
||||
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',
|
||||
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.',
|
||||
warrantyTitle: 'Warranty and returns',
|
||||
warrantyItem1: 'Warranty terms are provided by the seller and local law.',
|
||||
|
||||
@@ -267,8 +267,27 @@ export const hy: Translations = {
|
||||
reviewTextPlaceholder: 'Նկարագրեք ձեր փորձը',
|
||||
reviewAnonymous: 'Ուղարկել անանուն',
|
||||
reviewUploadPlaceholder: 'Լուսանկար վերբեռնելը նախատեսված է հաջորդ սպրինտում։',
|
||||
reviewRatingRequired: 'Խնդրում ենք ընտրել գնահատական։',
|
||||
reviewTextRequired: 'Խնդրում ենք գրել կարծիք։',
|
||||
reviewSuccess: 'Կարծիքը հաջողությամբ ուղարկվեց։',
|
||||
submitReview: 'Ուղարկել կարծիքը',
|
||||
submitting: 'Ուղարկվում է...',
|
||||
loadMore: 'Բեռնել ավելին',
|
||||
showingOf: 'Ցուցադրվում է {{shown}} / {{total}}',
|
||||
questionsSubmissionFuture: 'Հարցի ուղարկումը կարելի է միացնել ավելի ուշ՝ առանց բաղադրիչի փոփոխության։',
|
||||
specificationsGeneral: 'Ընդհանուր',
|
||||
actionsAria: 'Ապրանքի գործողություններ',
|
||||
buyNow: 'Գնել հիմա',
|
||||
wishlist: 'Նախընտրածներ',
|
||||
compare: 'Համեմատել',
|
||||
share: 'Կիսվել',
|
||||
notifyMe: 'Ծանուցել ինձ',
|
||||
zoom: 'Մեծացնել',
|
||||
fullscreen: 'Ամբողջ էկրան',
|
||||
pdfDocument: 'Ապրանքի PDF փաստաթուղթ',
|
||||
manualDocument: 'Ապրանքի ուղեցույց',
|
||||
warrantyDocument: 'Երաշխիքի փաստաթուղթ',
|
||||
openDocument: 'Բացել փաստաթուղթը',
|
||||
specificationsEmpty: 'Բնութագրերը դեռ հասանելի չեն։',
|
||||
warrantyTitle: 'Երաշխիք և վերադարձ',
|
||||
warrantyItem1: 'Երաշխիքի պայմանները սահմանվում են վաճառողի և տեղական օրենքներով։',
|
||||
|
||||
@@ -267,8 +267,27 @@ export const ru: Translations = {
|
||||
reviewTextPlaceholder: 'Опишите ваш опыт использования',
|
||||
reviewAnonymous: 'Отправить анонимно',
|
||||
reviewUploadPlaceholder: 'Загрузка фото запланирована в следующем спринте.',
|
||||
reviewRatingRequired: 'Выберите оценку.',
|
||||
reviewTextRequired: 'Добавьте текст отзыва.',
|
||||
reviewSuccess: 'Отзыв успешно отправлен.',
|
||||
submitReview: 'Отправить отзыв',
|
||||
submitting: 'Отправка...',
|
||||
loadMore: 'Показать еще',
|
||||
showingOf: 'Показано {{shown}} из {{total}}',
|
||||
questionsSubmissionFuture: 'Отправку вопросов можно включить позже без изменения компонента.',
|
||||
specificationsGeneral: 'Общие',
|
||||
actionsAria: 'Действия с товаром',
|
||||
buyNow: 'Купить сейчас',
|
||||
wishlist: 'В избранное',
|
||||
compare: 'Сравнить',
|
||||
share: 'Поделиться',
|
||||
notifyMe: 'Сообщить о наличии',
|
||||
zoom: 'Увеличить',
|
||||
fullscreen: 'Полный экран',
|
||||
pdfDocument: 'PDF документ товара',
|
||||
manualDocument: 'Инструкция товара',
|
||||
warrantyDocument: 'Гарантийный документ',
|
||||
openDocument: 'Открыть документ',
|
||||
specificationsEmpty: 'Характеристики пока не указаны.',
|
||||
warrantyTitle: 'Гарантия и возврат',
|
||||
warrantyItem1: 'Условия гарантии определяются продавцом и местным законодательством.',
|
||||
|
||||
@@ -265,8 +265,27 @@ export interface Translations {
|
||||
reviewTextPlaceholder: string;
|
||||
reviewAnonymous: string;
|
||||
reviewUploadPlaceholder: string;
|
||||
reviewRatingRequired: string;
|
||||
reviewTextRequired: string;
|
||||
reviewSuccess: string;
|
||||
submitReview: 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;
|
||||
warrantyTitle: string;
|
||||
warrantyItem1: string;
|
||||
|
||||
@@ -5,6 +5,18 @@ interface Photo {
|
||||
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 {
|
||||
key: string;
|
||||
value: string;
|
||||
@@ -59,6 +71,47 @@ interface ItemAttribute {
|
||||
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 {
|
||||
deliveryPrice: number;
|
||||
deliveryPlace: string;
|
||||
@@ -104,6 +157,10 @@ export interface Item {
|
||||
names?: ItemName[];
|
||||
descriptions?: ItemDescription[];
|
||||
attributes?: ItemAttribute[];
|
||||
media?: ProductMedia[];
|
||||
specificationGroups?: ProductSpecificationGroup[];
|
||||
variantOptions?: ProductVariantOptionGroup[];
|
||||
relatedCollections?: RelatedProductCollection[];
|
||||
|
||||
// BackOffice API fields
|
||||
id?: string;
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -7,10 +7,12 @@ export interface ProductRatingConfig extends ProductSectionConfig {}
|
||||
export interface ProductReviewsConfig extends ProductSectionConfig {
|
||||
pageSize?: number;
|
||||
showSummary?: boolean;
|
||||
mode?: 'pages' | 'load-more';
|
||||
}
|
||||
|
||||
export interface ProductQuestionsConfig extends ProductSectionConfig {
|
||||
pageSize?: number;
|
||||
allowSubmission?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductTabsConfig extends ProductSectionConfig {
|
||||
@@ -19,12 +21,22 @@ export interface ProductTabsConfig 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 {
|
||||
rating?: ProductRatingConfig;
|
||||
reviews?: ProductReviewsConfig;
|
||||
questions?: ProductQuestionsConfig;
|
||||
tabs?: ProductTabsConfig;
|
||||
relatedProducts?: ProductRelatedConfig;
|
||||
actions?: ProductActionsConfig;
|
||||
}
|
||||
|
||||
export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
|
||||
@@ -34,11 +46,13 @@ export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
|
||||
reviews: {
|
||||
enabled: true,
|
||||
pageSize: 5,
|
||||
showSummary: true
|
||||
showSummary: true,
|
||||
mode: 'pages'
|
||||
},
|
||||
questions: {
|
||||
enabled: true,
|
||||
pageSize: 5
|
||||
pageSize: 5,
|
||||
allowSubmission: true
|
||||
},
|
||||
tabs: {
|
||||
enabled: true,
|
||||
@@ -46,5 +60,14 @@ export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
|
||||
},
|
||||
relatedProducts: {
|
||||
enabled: true
|
||||
},
|
||||
actions: {
|
||||
enabled: true,
|
||||
addToCart: true,
|
||||
buyNow: true,
|
||||
wishlist: true,
|
||||
compare: true,
|
||||
share: true,
|
||||
notifyMe: true
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user