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
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
- stockStatus
- rating
- media
- specificationGroups
- variantOptions
- relatedCollections
## Product Engagement Models
- RatingSummary
@@ -41,6 +45,26 @@
- id, text, author, createdAt
- 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": [
{ "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,
"skills": {
"angular-developer": {
"source": "angular/skills",
"sourceType": "github",
"skillPath": "angular-developer/SKILL.md",
"computedHash": "62e087c9cf0dc17f4ca4fed9f451f65605f43e4427016eb799409d6da39a0a87"
},
"cavecrew": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
@@ -42,6 +48,12 @@
"sourceType": "github",
"skillPath": "skills/caveman-stats/SKILL.md",
"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;
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">
<div class="product-gallery-main">
@if (media[selectedIndex]?.video) {
<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>
} @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>
@@ -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>

View File

@@ -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));

View File

@@ -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';
}
}

View File

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

View File

@@ -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>();

View File

@@ -1,15 +1,20 @@
<section class="product-specifications card section">
<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>
@for (field of descriptionFields; track field.key) {
@for (field of group.fields; track field.key) {
<div>
<dt>{{ field.key }}</dt>
<dt>{{ field.label | translate }}</dt>
<dd>{{ field.value }}</dd>
</div>
}
</dl>
</section>
}
} @else {
<p>{{ 'productDetails.specificationsEmpty' | translate }}</p>
}

View File

@@ -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;

View File

@@ -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,
}];
}
}

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">
<h2>{{ 'itemDetail.relatedProducts' | translate }}</h2>
<h2>{{ collection.title | translate }}</h2>
<div class="related-grid">
@for (product of products; track trackByItemId($index, product)) {
@for (product of collection.products; track trackByItemId($index, product)) {
<app-product-card
[item]="product"
[title]="productTitle(product)"
@@ -14,4 +15,5 @@
}
</div>
</section>
}
}

View File

@@ -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());
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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)"
/>

View File

@@ -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 => {
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 ?? {})
}
};
}

View File

@@ -1,9 +1,13 @@
<section class="question-list section">
@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)" />
}

View File

@@ -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;

View File

@@ -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>();

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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.',

View File

@@ -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: 'Երաշխիքի պայմանները սահմանվում են վաճառողի և տեղական օրենքներով։',

View File

@@ -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: 'Условия гарантии определяются продавцом и местным законодательством.',

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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
}
};