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:
@@ -155,3 +155,118 @@ npm run build
|
|||||||
## Stop Point
|
## Stop Point
|
||||||
|
|
||||||
Product Details Module implementation is complete for Sprint 6. Stop here for approval before starting any further module work.
|
Product Details Module implementation is complete for Sprint 6. Stop here for approval before starting any further module work.
|
||||||
|
|
||||||
|
## Sprint 11 - Product Experience 2.0
|
||||||
|
|
||||||
|
Sprint 11 extends Product Details UX with reusable modules and config-driven behavior. Architecture, Widget Manifest, Section Engine, bootstrap loading, authentication, and payment logic remain unchanged.
|
||||||
|
|
||||||
|
### New Reusable Modules
|
||||||
|
|
||||||
|
- Product Actions module
|
||||||
|
- `Add to Cart`, `Buy Now`, `Wishlist`, `Compare`, `Share`, `Notify Me`
|
||||||
|
- `productPage.actions` controls visibility of each action
|
||||||
|
- Product Gallery upgrade
|
||||||
|
- Media renderer by type (`image`, `video`, `pdf`, `manual`, `warranty`)
|
||||||
|
- Thumbnail strip + active media
|
||||||
|
- Mobile swipe support
|
||||||
|
- Zoom/fullscreen extension events (`zoomRequested`, `fullscreenRequested`)
|
||||||
|
- Product Specifications upgrade
|
||||||
|
- Grouped attributes via `specificationGroups`
|
||||||
|
- Translated labels via `labels` map
|
||||||
|
- Backward-compatible fallback to `descriptionFields` and `attributes`
|
||||||
|
- Variant Selector upgrade
|
||||||
|
- Dynamic option groups via `variantOptions`
|
||||||
|
- No hardcoded variant keys required
|
||||||
|
- Backward-compatible color/size fallback
|
||||||
|
- Reviews upgrade
|
||||||
|
- Existing pagination preserved
|
||||||
|
- Optional `load-more` mode added via config
|
||||||
|
- Review form adds explicit validation and success state
|
||||||
|
- Questions & Answers upgrade
|
||||||
|
- Existing list/answers/accepted-answer behavior preserved
|
||||||
|
- Submission can be toggled off while component remains future-ready
|
||||||
|
- Related Products upgrade
|
||||||
|
- Supports multiple backend-provided collections
|
||||||
|
- Falls back to previous single related collection behavior
|
||||||
|
|
||||||
|
### Config Additions (backward-compatible)
|
||||||
|
|
||||||
|
`productPage` supports new optional keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reviews": {
|
||||||
|
"mode": "pages"
|
||||||
|
},
|
||||||
|
"questions": {
|
||||||
|
"allowSubmission": true
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"enabled": true,
|
||||||
|
"addToCart": true,
|
||||||
|
"buyNow": true,
|
||||||
|
"wishlist": true,
|
||||||
|
"compare": true,
|
||||||
|
"share": true,
|
||||||
|
"notifyMe": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Product JSON Extension Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"itemID": 7812,
|
||||||
|
"name": "Laptop Pro 14",
|
||||||
|
"media": [
|
||||||
|
{ "type": "image", "url": "https://cdn.example.com/items/7812/main.jpg" },
|
||||||
|
{ "type": "video", "url": "https://cdn.example.com/items/7812/demo.mp4" },
|
||||||
|
{ "type": "pdf", "url": "https://cdn.example.com/items/7812/spec.pdf" },
|
||||||
|
{ "type": "manual", "url": "https://cdn.example.com/items/7812/manual.pdf" },
|
||||||
|
{ "type": "warranty", "url": "https://cdn.example.com/items/7812/warranty.pdf" }
|
||||||
|
],
|
||||||
|
"specificationGroups": [
|
||||||
|
{
|
||||||
|
"key": "display",
|
||||||
|
"label": "Display",
|
||||||
|
"labels": { "en": "Display", "ru": "Экран", "hy": "Էկրան" },
|
||||||
|
"attributes": [
|
||||||
|
{ "key": "size", "value": "14", "unit": "inch", "labels": { "en": "Size", "ru": "Диагональ", "hy": "Չափ" } },
|
||||||
|
{ "key": "resolution", "value": "2880x1800", "labels": { "en": "Resolution", "ru": "Разрешение", "hy": "Լուծաչափ" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variantOptions": [
|
||||||
|
{
|
||||||
|
"key": "storage",
|
||||||
|
"label": "Storage",
|
||||||
|
"options": [
|
||||||
|
{ "value": "256GB" },
|
||||||
|
{ "value": "512GB" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relatedCollections": [
|
||||||
|
{
|
||||||
|
"id": "frequently-bought-together",
|
||||||
|
"title": "Frequently bought together",
|
||||||
|
"titles": { "ru": "Покупают вместе", "hy": "Հաճախ գնում են միասին" },
|
||||||
|
"products": [9021, 9022, 9023]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "similar-products",
|
||||||
|
"title": "Similar products",
|
||||||
|
"products": [9030, 9031]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extension Points
|
||||||
|
|
||||||
|
- Gallery overlays can subscribe to `zoomRequested` and `fullscreenRequested` without changing gallery internals.
|
||||||
|
- Variant logic can add new option groups from backend by extending `variantOptions` only.
|
||||||
|
- Specifications can add locale labels without frontend refactor.
|
||||||
|
- Related collections can add campaign-specific blocks without changing section engine.
|
||||||
|
- Action visibility and review mode can be tuned from config without component rewrites.
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
- attributes
|
- attributes
|
||||||
- stockStatus
|
- stockStatus
|
||||||
- rating
|
- rating
|
||||||
|
- media
|
||||||
|
- specificationGroups
|
||||||
|
- variantOptions
|
||||||
|
- relatedCollections
|
||||||
|
|
||||||
## Product Engagement Models
|
## Product Engagement Models
|
||||||
- RatingSummary
|
- RatingSummary
|
||||||
@@ -41,6 +45,26 @@
|
|||||||
- id, text, author, createdAt
|
- id, text, author, createdAt
|
||||||
- isOfficialSeller, isAccepted
|
- isOfficialSeller, isAccepted
|
||||||
|
|
||||||
|
## Product Experience 2.0 Optional Contracts
|
||||||
|
|
||||||
|
- media: `[{ type, url, thumbnailUrl?, alt?, title?, labels? }]`
|
||||||
|
- type: `image | video | pdf | manual | warranty`
|
||||||
|
- frontend renderer picks viewer by `type`
|
||||||
|
- specificationGroups: `[{ key, label?, labels?, attributes: [{ key, value, label?, labels?, unit? }] }]`
|
||||||
|
- supports grouped specifications and translated labels
|
||||||
|
- variantOptions: `[{ key, label?, labels?, options: [{ value, label?, labels?, available? }] }]`
|
||||||
|
- supports arbitrary variant groups (`color`, `size`, `storage`, etc.)
|
||||||
|
- relatedCollections: `[{ id, title, titles?, products: number[] }]`
|
||||||
|
- supports multiple related collections from backend
|
||||||
|
|
||||||
|
## Product Page Config Extensions
|
||||||
|
|
||||||
|
`productPage` optional config additions:
|
||||||
|
|
||||||
|
- reviews.mode: `pages | load-more`
|
||||||
|
- questions.allowSubmission: `boolean`
|
||||||
|
- actions: `{ enabled, addToCart, buyNow, wishlist, compare, share, notifyMe }`
|
||||||
|
|
||||||
## Строгие правила
|
## Строгие правила
|
||||||
- Цена и валюта должны передаваться как валидная пара.
|
- Цена и валюта должны передаваться как валидная пара.
|
||||||
- Скрытые товары не участвуют в публичных витринах.
|
- Скрытые товары не участвуют в публичных витринах.
|
||||||
@@ -63,7 +87,35 @@
|
|||||||
"images": [
|
"images": [
|
||||||
{ "url": "https://cdn.example.com/items/7812/main.jpg", "isMain": true }
|
{ "url": "https://cdn.example.com/items/7812/main.jpg", "isMain": true }
|
||||||
],
|
],
|
||||||
"badges": ["featured", "new"]
|
"badges": ["featured", "new"],
|
||||||
|
"media": [
|
||||||
|
{ "type": "image", "url": "https://cdn.example.com/items/7812/main.jpg" },
|
||||||
|
{ "type": "video", "url": "https://cdn.example.com/items/7812/demo.mp4" },
|
||||||
|
{ "type": "pdf", "url": "https://cdn.example.com/items/7812/spec.pdf" }
|
||||||
|
],
|
||||||
|
"specificationGroups": [
|
||||||
|
{
|
||||||
|
"key": "display",
|
||||||
|
"labels": { "en": "Display", "ru": "Экран" },
|
||||||
|
"attributes": [
|
||||||
|
{ "key": "size", "value": "14", "unit": "inch" },
|
||||||
|
{ "key": "resolution", "value": "2880x1800" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variantOptions": [
|
||||||
|
{
|
||||||
|
"key": "storage",
|
||||||
|
"options": [{ "value": "256GB" }, { "value": "512GB" }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relatedCollections": [
|
||||||
|
{
|
||||||
|
"id": "similar-products",
|
||||||
|
"title": "Похожие товары",
|
||||||
|
"products": [9030, 9031]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,3 +128,4 @@
|
|||||||
- Возвращать актуальные цены и доступность.
|
- Возвращать актуальные цены и доступность.
|
||||||
- Стабильно поддерживать идентификаторы товаров.
|
- Стабильно поддерживать идентификаторы товаров.
|
||||||
- Предоставлять медиа и атрибуты в согласованном формате.
|
- Предоставлять медиа и атрибуты в согласованном формате.
|
||||||
|
- Поддерживать обратную совместимость: новые поля опциональны, старые payload остаются валидными.
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"skills": {
|
"skills": {
|
||||||
|
"angular-developer": {
|
||||||
|
"source": "angular/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "angular-developer/SKILL.md",
|
||||||
|
"computedHash": "62e087c9cf0dc17f4ca4fed9f451f65605f43e4427016eb799409d6da39a0a87"
|
||||||
|
},
|
||||||
"cavecrew": {
|
"cavecrew": {
|
||||||
"source": "JuliusBrussee/caveman",
|
"source": "JuliusBrussee/caveman",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
@@ -42,6 +48,12 @@
|
|||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/caveman-stats/SKILL.md",
|
"skillPath": "skills/caveman-stats/SKILL.md",
|
||||||
"computedHash": "331f720e2fa97b68cacdae44384878071e8cac6013479edea68f4c8eca308852"
|
"computedHash": "331f720e2fa97b68cacdae44384878071e8cac6013479edea68f4c8eca308852"
|
||||||
|
},
|
||||||
|
"design-taste-frontend": {
|
||||||
|
"source": "Leonxlnx/taste-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/taste-skill/SKILL.md",
|
||||||
|
"computedHash": "899b84384f74f540ea5284d9b2e9234e050998b42eacc805410b518d4226c0b3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,3 +36,14 @@ export interface RelatedProductsQuery {
|
|||||||
categoryID?: number;
|
categoryID?: number;
|
||||||
count?: number;
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RelatedProductCollection {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
titles?: Record<string, string>;
|
||||||
|
productIDs: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductVariantSelection {
|
||||||
|
[groupKey: string]: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
<section class="product-gallery" [attr.aria-label]="'productDetails.mediaAria' | translate">
|
||||||
<div class="product-gallery-main">
|
<div class="product-gallery-main" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
|
||||||
@if (media[selectedIndex]?.video) {
|
<div class="product-gallery-toolbar">
|
||||||
|
<button type="button" class="toolbar-btn" (click)="zoomRequested.emit(selectedIndex)">{{ 'productDetails.zoom' | translate }}</button>
|
||||||
|
<button type="button" class="toolbar-btn" (click)="fullscreenRequested.emit(selectedIndex)">{{ 'productDetails.fullscreen' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@switch (media[selectedIndex]?.type) {
|
||||||
|
@case ('video') {
|
||||||
<video [src]="media[selectedIndex].url" controls></video>
|
<video [src]="media[selectedIndex].url" controls></video>
|
||||||
} @else {
|
}
|
||||||
<img [src]="media[selectedIndex].url" [alt]="product.name" loading="eager" decoding="async" />
|
|
||||||
|
@case ('pdf') {
|
||||||
|
<object [data]="media[selectedIndex].url" type="application/pdf" [attr.aria-label]="'productDetails.pdfDocument' | translate">
|
||||||
|
<a [href]="media[selectedIndex].url" target="_blank" rel="noopener">{{ 'productDetails.openDocument' | translate }}</a>
|
||||||
|
</object>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('manual') {
|
||||||
|
<div class="document-viewer">
|
||||||
|
<p>{{ 'productDetails.manualDocument' | translate }}</p>
|
||||||
|
<a [href]="media[selectedIndex].url" target="_blank" rel="noopener">{{ 'productDetails.openDocument' | translate }}</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('warranty') {
|
||||||
|
<div class="document-viewer">
|
||||||
|
<p>{{ 'productDetails.warrantyDocument' | translate }}</p>
|
||||||
|
<a [href]="media[selectedIndex].url" target="_blank" rel="noopener">{{ 'productDetails.openDocument' | translate }}</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@default {
|
||||||
|
<img [src]="media[selectedIndex].url" [alt]="media[selectedIndex].alt" loading="eager" decoding="async" />
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -11,10 +40,12 @@
|
|||||||
<div class="product-gallery-thumbs">
|
<div class="product-gallery-thumbs">
|
||||||
@for (item of media; track $index) {
|
@for (item of media; track $index) {
|
||||||
<button type="button" class="product-gallery-thumb" [class.active]="selectedIndex === $index" (click)="select($index)">
|
<button type="button" class="product-gallery-thumb" [class.active]="selectedIndex === $index" (click)="select($index)">
|
||||||
@if (item.video) {
|
@if (item.type === 'video') {
|
||||||
<span class="product-gallery-video">▶</span>
|
<span class="product-gallery-video">▶</span>
|
||||||
|
} @else if (item.type !== 'image') {
|
||||||
|
<span class="product-gallery-doc">{{ item.type }}</span>
|
||||||
}
|
}
|
||||||
<img [src]="item.url" [alt]="product.name + ' ' + ($index + 1)" loading="lazy" decoding="async" />
|
<img [src]="item.thumbnailUrl" [alt]="item.alt + ' ' + ($index + 1)" loading="lazy" decoding="async" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-gallery-main {
|
.product-gallery-main {
|
||||||
|
position: relative;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border: 1px solid #d3dad9;
|
border: 1px solid #d3dad9;
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
@@ -16,10 +17,58 @@
|
|||||||
transition: box-shadow 0.24s ease, border-color 0.24s ease;
|
transition: box-shadow 0.24s ease, border-color 0.24s ease;
|
||||||
|
|
||||||
img,
|
img,
|
||||||
video {
|
video,
|
||||||
|
object {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-gallery-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
min-height: 30px;
|
||||||
|
border: 1px solid rgba(30, 60, 56, 0.25);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #1e3c38;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #1e3c38;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #497671;
|
||||||
|
color: #2e5e59;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +116,19 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-gallery-doc {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(30, 60, 56, 0.78);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.product-gallery-thumbs {
|
.product-gallery-thumbs {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(58px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(58px, 1fr));
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { Product } from '../../../../../core/products/models/product-domain.mode
|
|||||||
import { getMainImage } from '../../../../../utils/item.utils';
|
import { getMainImage } from '../../../../../utils/item.utils';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
|
||||||
|
type ProductMediaType = 'image' | 'video' | 'pdf' | 'manual' | 'warranty';
|
||||||
|
|
||||||
|
interface GalleryMediaItem {
|
||||||
|
id: string;
|
||||||
|
type: ProductMediaType;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
alt: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-gallery',
|
selector: 'app-product-gallery',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -16,14 +26,82 @@ export class ProductGalleryComponent {
|
|||||||
@Input() selectedIndex = 0;
|
@Input() selectedIndex = 0;
|
||||||
|
|
||||||
@Output() selectedIndexChange = new EventEmitter<number>();
|
@Output() selectedIndexChange = new EventEmitter<number>();
|
||||||
|
@Output() zoomRequested = new EventEmitter<number>();
|
||||||
|
@Output() fullscreenRequested = new EventEmitter<number>();
|
||||||
|
|
||||||
readonly getMainImage = getMainImage;
|
readonly getMainImage = getMainImage;
|
||||||
|
|
||||||
get media() {
|
private touchStartX = 0;
|
||||||
return this.product.photos?.length ? this.product.photos : [{ url: this.getMainImage(this.product) }];
|
private touchStartY = 0;
|
||||||
|
|
||||||
|
get media(): GalleryMediaItem[] {
|
||||||
|
if (this.product.media?.length) {
|
||||||
|
return this.product.media.map((item, index) => ({
|
||||||
|
id: item.id ?? `media-${index}`,
|
||||||
|
type: item.type,
|
||||||
|
url: item.url,
|
||||||
|
thumbnailUrl: item.thumbnailUrl ?? item.url,
|
||||||
|
alt: item.alt || item.title || this.product.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.product.photos?.length) {
|
||||||
|
return this.product.photos.map((item, index) => ({
|
||||||
|
id: item.photo ?? `photo-${index}`,
|
||||||
|
type: item.video ? 'video' : this.resolveLegacyType(item.type),
|
||||||
|
url: item.url,
|
||||||
|
thumbnailUrl: item.url,
|
||||||
|
alt: this.product.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
id: 'fallback-image',
|
||||||
|
type: 'image',
|
||||||
|
url: this.getMainImage(this.product),
|
||||||
|
thumbnailUrl: this.getMainImage(this.product),
|
||||||
|
alt: this.product.name,
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
select(index: number): void {
|
select(index: number): void {
|
||||||
this.selectedIndexChange.emit(index);
|
this.selectedIndexChange.emit(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTouchStart(event: TouchEvent): void {
|
||||||
|
if (event.touches.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.touchStartX = event.touches[0].clientX;
|
||||||
|
this.touchStartY = event.touches[0].clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd(event: TouchEvent): void {
|
||||||
|
if (event.changedTouches.length === 0 || this.media.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = event.changedTouches[0].clientX - this.touchStartX;
|
||||||
|
const deltaY = event.changedTouches[0].clientY - this.touchStartY;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) < 36 || Math.abs(deltaX) < Math.abs(deltaY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = deltaX > 0 ? -1 : 1;
|
||||||
|
const next = Math.min(Math.max(0, this.selectedIndex + direction), this.media.length - 1);
|
||||||
|
if (next !== this.selectedIndex) {
|
||||||
|
this.selectedIndexChange.emit(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveLegacyType(type: string | undefined): ProductMediaType {
|
||||||
|
const normalized = (type ?? '').toLowerCase();
|
||||||
|
if (normalized === 'video') return 'video';
|
||||||
|
if (normalized === 'pdf') return 'pdf';
|
||||||
|
if (normalized === 'manual') return 'manual';
|
||||||
|
if (normalized === 'warranty') return 'warranty';
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (showAddToCart) {
|
||||||
<button type="button" class="add-to-cart" (click)="addToCart.emit()">
|
<button type="button" class="add-to-cart" (click)="addToCart.emit()">
|
||||||
{{ 'itemDetail.addToCart' | translate }}
|
{{ 'itemDetail.addToCart' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class ProductInformationComponent {
|
|||||||
@Input() currency = '';
|
@Input() currency = '';
|
||||||
@Input() remaining: number | null = null;
|
@Input() remaining: number | null = null;
|
||||||
@Input() stockStatus = 'high';
|
@Input() stockStatus = 'high';
|
||||||
|
@Input() showAddToCart = true;
|
||||||
|
|
||||||
@Output() addToCart = new EventEmitter<void>();
|
@Output() addToCart = new EventEmitter<void>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<section class="product-specifications card section">
|
<section class="product-specifications card section">
|
||||||
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
|
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
|
||||||
|
|
||||||
@if (descriptionFields.length > 0) {
|
@if (groups.length > 0) {
|
||||||
|
@for (group of groups; track group.key) {
|
||||||
|
<section class="spec-group">
|
||||||
|
<h4>{{ group.label | translate }}</h4>
|
||||||
<dl>
|
<dl>
|
||||||
@for (field of descriptionFields; track field.key) {
|
@for (field of group.fields; track field.key) {
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ field.key }}</dt>
|
<dt>{{ field.label | translate }}</dt>
|
||||||
<dd>{{ field.value }}</dd>
|
<dd>{{ field.value }}</dd>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<p>{{ 'productDetails.specificationsEmpty' | translate }}</p>
|
<p>{{ 'productDetails.specificationsEmpty' | translate }}</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ h3 {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spec-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
dl {
|
dl {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
import { LanguageService } from '../../../../../services/language.service';
|
||||||
|
|
||||||
|
interface SpecificationViewField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpecificationViewGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
fields: SpecificationViewField[];
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-specifications',
|
selector: 'app-product-specifications',
|
||||||
@@ -13,7 +26,47 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
|||||||
export class ProductSpecificationsComponent {
|
export class ProductSpecificationsComponent {
|
||||||
@Input({ required: true }) product!: Product;
|
@Input({ required: true }) product!: Product;
|
||||||
|
|
||||||
get descriptionFields() {
|
constructor(private readonly languageService: LanguageService) {}
|
||||||
return this.product.descriptionFields ?? [];
|
|
||||||
|
get groups(): SpecificationViewGroup[] {
|
||||||
|
const locale = this.languageService.currentLanguage();
|
||||||
|
const groups = this.product.specificationGroups ?? [];
|
||||||
|
|
||||||
|
if (groups.length > 0) {
|
||||||
|
return groups
|
||||||
|
.map(group => ({
|
||||||
|
key: group.key,
|
||||||
|
label: group.labels?.[locale] || group.label || group.key,
|
||||||
|
fields: (group.attributes ?? []).map(attribute => ({
|
||||||
|
key: attribute.key,
|
||||||
|
label: attribute.labels?.[locale] || attribute.label || attribute.key,
|
||||||
|
value: attribute.unit ? `${attribute.value} ${attribute.unit}` : attribute.value,
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.filter(group => group.fields.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackFields = [
|
||||||
|
...(this.product.descriptionFields ?? []).map(field => ({
|
||||||
|
key: field.key,
|
||||||
|
label: field.key,
|
||||||
|
value: field.value,
|
||||||
|
})),
|
||||||
|
...(this.product.attributes ?? []).map(attribute => ({
|
||||||
|
key: attribute.key,
|
||||||
|
label: attribute.key,
|
||||||
|
value: attribute.value,
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
if (fallbackFields.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
key: 'general',
|
||||||
|
label: 'productDetails.specificationsGeneral',
|
||||||
|
fields: fallbackFields,
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
@if (products.length > 0) {
|
@if (normalizedCollections.length > 0) {
|
||||||
|
@for (collection of normalizedCollections; track collection.id) {
|
||||||
<section class="related-products">
|
<section class="related-products">
|
||||||
<h2>{{ 'itemDetail.relatedProducts' | translate }}</h2>
|
<h2>{{ collection.title | translate }}</h2>
|
||||||
<div class="related-grid">
|
<div class="related-grid">
|
||||||
@for (product of products; track trackByItemId($index, product)) {
|
@for (product of collection.products; track trackByItemId($index, product)) {
|
||||||
<app-product-card
|
<app-product-card
|
||||||
[item]="product"
|
[item]="product"
|
||||||
[title]="productTitle(product)"
|
[title]="productTitle(product)"
|
||||||
@@ -15,3 +16,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
|||||||
import { LanguageService } from '../../../../../services/language.service';
|
import { LanguageService } from '../../../../../services/language.service';
|
||||||
import { getTranslatedField, trackByItemId } from '../../../../../utils/item.utils';
|
import { getTranslatedField, trackByItemId } from '../../../../../utils/item.utils';
|
||||||
|
|
||||||
|
export interface RelatedCollectionViewModel {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
products: Product[];
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-related-products',
|
selector: 'app-related-products',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -15,6 +21,7 @@ import { getTranslatedField, trackByItemId } from '../../../../../utils/item.uti
|
|||||||
})
|
})
|
||||||
export class RelatedProductsComponent {
|
export class RelatedProductsComponent {
|
||||||
@Input() products: Product[] = [];
|
@Input() products: Product[] = [];
|
||||||
|
@Input() collections: RelatedCollectionViewModel[] = [];
|
||||||
|
|
||||||
@Output() productSelected = new EventEmitter<Product>();
|
@Output() productSelected = new EventEmitter<Product>();
|
||||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||||
@@ -23,6 +30,22 @@ export class RelatedProductsComponent {
|
|||||||
|
|
||||||
readonly trackByItemId = trackByItemId;
|
readonly trackByItemId = trackByItemId;
|
||||||
|
|
||||||
|
get normalizedCollections(): RelatedCollectionViewModel[] {
|
||||||
|
if (this.collections.length > 0) {
|
||||||
|
return this.collections.filter(collection => collection.products.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.products.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
id: 'default-related-products',
|
||||||
|
title: 'itemDetail.relatedProducts',
|
||||||
|
products: this.products,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
productTitle(product: Product): string {
|
productTitle(product: Product): string {
|
||||||
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
|
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,28 @@
|
|||||||
@if (hasVariants) {
|
@if (hasVariants) {
|
||||||
<section class="variant-selector">
|
<section class="variant-selector">
|
||||||
@if (colours.length > 0) {
|
@for (group of normalizedGroups; track group.key) {
|
||||||
<div class="variant-group">
|
<div class="variant-group">
|
||||||
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span>
|
<span class="variant-label">{{ group.label | translate }}</span>
|
||||||
<div class="variant-options">
|
<div class="variant-options">
|
||||||
@for (colour of colours; track $index) {
|
@for (option of group.options; track option.value) {
|
||||||
<button type="button" class="colour-swatch" [class.active]="selectedColour === colour" [style.background-color]="colour" [attr.aria-label]="colour" (click)="colourSelected.emit(colour)"></button>
|
@if (group.key === 'colour') {
|
||||||
|
<button type="button" class="colour-swatch" [class.active]="isActive(group.key, option.value)" [style.background-color]="option.value" [attr.aria-label]="option.label || option.value" (click)="onSelect(group.key, option.value)"></button>
|
||||||
|
} @else {
|
||||||
|
<button type="button" class="size-chip" [class.active]="isActive(group.key, option.value)" (click)="onSelect(group.key, option.value)">{{ option.label || option.value }}</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else if (fallbackColour) {
|
}
|
||||||
|
|
||||||
|
@if (normalizedGroups.length === 0 && fallbackColour) {
|
||||||
<div class="variant-group">
|
<div class="variant-group">
|
||||||
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span>
|
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span>
|
||||||
<span class="colour-swatch readonly" [style.background-color]="fallbackColour"></span>
|
<span class="colour-swatch readonly" [style.background-color]="fallbackColour"></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (sizes.length > 0) {
|
@if (normalizedGroups.length === 0 && visibleFallbackSize) {
|
||||||
<div class="variant-group">
|
|
||||||
<span class="variant-label">{{ 'itemDetail.size' | translate }}</span>
|
|
||||||
<div class="variant-options">
|
|
||||||
@for (size of sizes; track $index) {
|
|
||||||
<button type="button" class="size-chip" [class.active]="selectedSize === size" (click)="sizeSelected.emit(size)">{{ size }}</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else if (visibleFallbackSize) {
|
|
||||||
<div class="variant-group">
|
<div class="variant-group">
|
||||||
<span class="variant-label">{{ 'itemDetail.size' | translate }}</span>
|
<span class="variant-label">{{ 'itemDetail.size' | translate }}</span>
|
||||||
<span class="size-chip readonly">{{ visibleFallbackSize }}</span>
|
<span class="size-chip readonly">{{ visibleFallbackSize }}</span>
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
|
||||||
|
export interface VariantOption {
|
||||||
|
value: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VariantOptionGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
options: VariantOption[];
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-variant-selector',
|
selector: 'app-product-variant-selector',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -10,6 +21,9 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class ProductVariantSelectorComponent {
|
export class ProductVariantSelectorComponent {
|
||||||
|
@Input() optionGroups: VariantOptionGroup[] = [];
|
||||||
|
@Input() selectedOptions: Record<string, string> = {};
|
||||||
|
|
||||||
@Input() colours: string[] = [];
|
@Input() colours: string[] = [];
|
||||||
@Input() sizes: string[] = [];
|
@Input() sizes: string[] = [];
|
||||||
@Input() selectedColour: string | null = null;
|
@Input() selectedColour: string | null = null;
|
||||||
@@ -17,11 +31,62 @@ export class ProductVariantSelectorComponent {
|
|||||||
@Input() fallbackColour: string | null = null;
|
@Input() fallbackColour: string | null = null;
|
||||||
@Input() fallbackSize: string | null = null;
|
@Input() fallbackSize: string | null = null;
|
||||||
|
|
||||||
|
@Output() optionSelected = new EventEmitter<{ groupKey: string; value: string }>();
|
||||||
@Output() colourSelected = new EventEmitter<string>();
|
@Output() colourSelected = new EventEmitter<string>();
|
||||||
@Output() sizeSelected = new EventEmitter<string>();
|
@Output() sizeSelected = new EventEmitter<string>();
|
||||||
|
|
||||||
get hasVariants(): boolean {
|
get hasVariants(): boolean {
|
||||||
return this.colours.length > 0 || this.sizes.length > 0 || !!this.fallbackColour || !!this.visibleFallbackSize;
|
return this.normalizedGroups.length > 0 || !!this.fallbackColour || !!this.visibleFallbackSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
get normalizedGroups(): VariantOptionGroup[] {
|
||||||
|
if (this.optionGroups.length > 0) {
|
||||||
|
return this.optionGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: VariantOptionGroup[] = [];
|
||||||
|
if (this.colours.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
key: 'colour',
|
||||||
|
label: 'itemDetail.colour',
|
||||||
|
options: this.colours.map(value => ({ value }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sizes.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
key: 'size',
|
||||||
|
label: 'itemDetail.size',
|
||||||
|
options: this.sizes.map(value => ({ value }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(groupKey: string, value: string): boolean {
|
||||||
|
if (groupKey === 'colour') {
|
||||||
|
return this.selectedColour === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupKey === 'size') {
|
||||||
|
return this.selectedSize === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.selectedOptions[groupKey] === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(groupKey: string, value: string): void {
|
||||||
|
this.optionSelected.emit({ groupKey, value });
|
||||||
|
|
||||||
|
if (groupKey === 'colour') {
|
||||||
|
this.colourSelected.emit(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupKey === 'size') {
|
||||||
|
this.sizeSelected.emit(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get visibleFallbackSize(): string | null {
|
get visibleFallbackSize(): string | null {
|
||||||
|
|||||||
@@ -40,16 +40,32 @@
|
|||||||
[currency]="effectiveCurrency()"
|
[currency]="effectiveCurrency()"
|
||||||
[remaining]="effectiveRemaining()"
|
[remaining]="effectiveRemaining()"
|
||||||
[stockStatus]="stockStatus()"
|
[stockStatus]="stockStatus()"
|
||||||
|
[showAddToCart]="false"
|
||||||
(addToCart)="addToCart()"
|
(addToCart)="addToCart()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<app-product-actions
|
||||||
|
[config]="actionsConfig()"
|
||||||
|
[outOfStock]="stockStatus() === 'out'"
|
||||||
|
[inWishlist]="isInWishlist()"
|
||||||
|
[inCompare]="isInCompare()"
|
||||||
|
(addToCart)="addToCart()"
|
||||||
|
(buyNow)="buyNow()"
|
||||||
|
(wishlist)="toggleWishlist()"
|
||||||
|
(compare)="toggleCompare()"
|
||||||
|
(share)="shareProduct()"
|
||||||
|
(notifyMe)="notifyMe()" />
|
||||||
|
|
||||||
<app-product-variant-selector
|
<app-product-variant-selector
|
||||||
|
[optionGroups]="variantOptionGroups()"
|
||||||
|
[selectedOptions]="selectedOptions()"
|
||||||
[colours]="availableColours()"
|
[colours]="availableColours()"
|
||||||
[sizes]="availableSizes()"
|
[sizes]="availableSizes()"
|
||||||
[selectedColour]="selectedColour()"
|
[selectedColour]="selectedColour()"
|
||||||
[selectedSize]="selectedSize()"
|
[selectedSize]="selectedSize()"
|
||||||
[fallbackColour]="currentProduct.colour ?? null"
|
[fallbackColour]="currentProduct.colour ?? null"
|
||||||
[fallbackSize]="currentProduct.size ?? null"
|
[fallbackSize]="currentProduct.size ?? null"
|
||||||
|
(optionSelected)="selectVariantOption($event)"
|
||||||
(colourSelected)="selectColour($event)"
|
(colourSelected)="selectColour($event)"
|
||||||
(sizeSelected)="selectSize($event)"
|
(sizeSelected)="selectSize($event)"
|
||||||
/>
|
/>
|
||||||
@@ -82,7 +98,9 @@
|
|||||||
[loading]="reviewsLoading() || ratingLoading()"
|
[loading]="reviewsLoading() || ratingLoading()"
|
||||||
[submitting]="reviewSubmitting()"
|
[submitting]="reviewSubmitting()"
|
||||||
[showSummary]="productPageConfigState().reviews.showSummary !== false"
|
[showSummary]="productPageConfigState().reviews.showSummary !== false"
|
||||||
|
[mode]="reviewsMode()"
|
||||||
(pageChange)="loadReviews($event)"
|
(pageChange)="loadReviews($event)"
|
||||||
|
(loadMore)="loadReviews($event, true)"
|
||||||
(submitReview)="submitReview($event)" />
|
(submitReview)="submitReview($event)" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +109,7 @@
|
|||||||
[result]="questionsResult()"
|
[result]="questionsResult()"
|
||||||
[loading]="questionsLoading()"
|
[loading]="questionsLoading()"
|
||||||
[submitting]="questionSubmitting()"
|
[submitting]="questionSubmitting()"
|
||||||
|
[allowSubmission]="productPageConfigState().questions.allowSubmission !== false"
|
||||||
(pageChange)="loadQuestions($event)"
|
(pageChange)="loadQuestions($event)"
|
||||||
(submitQuestion)="submitQuestion($event)" />
|
(submitQuestion)="submitQuestion($event)" />
|
||||||
}
|
}
|
||||||
@@ -122,7 +141,9 @@
|
|||||||
[loading]="reviewsLoading() || ratingLoading()"
|
[loading]="reviewsLoading() || ratingLoading()"
|
||||||
[submitting]="reviewSubmitting()"
|
[submitting]="reviewSubmitting()"
|
||||||
[showSummary]="productPageConfigState().reviews.showSummary !== false"
|
[showSummary]="productPageConfigState().reviews.showSummary !== false"
|
||||||
|
[mode]="reviewsMode()"
|
||||||
(pageChange)="loadReviews($event)"
|
(pageChange)="loadReviews($event)"
|
||||||
|
(loadMore)="loadReviews($event, true)"
|
||||||
(submitReview)="submitReview($event)" />
|
(submitReview)="submitReview($event)" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +152,7 @@
|
|||||||
[result]="questionsResult()"
|
[result]="questionsResult()"
|
||||||
[loading]="questionsLoading()"
|
[loading]="questionsLoading()"
|
||||||
[submitting]="questionSubmitting()"
|
[submitting]="questionSubmitting()"
|
||||||
|
[allowSubmission]="productPageConfigState().questions.allowSubmission !== false"
|
||||||
(pageChange)="loadQuestions($event)"
|
(pageChange)="loadQuestions($event)"
|
||||||
(submitQuestion)="submitQuestion($event)" />
|
(submitQuestion)="submitQuestion($event)" />
|
||||||
}
|
}
|
||||||
@@ -148,6 +170,7 @@
|
|||||||
@if (productPageConfigState().relatedProducts.enabled !== false) {
|
@if (productPageConfigState().relatedProducts.enabled !== false) {
|
||||||
<app-related-products
|
<app-related-products
|
||||||
[products]="relatedProducts()"
|
[products]="relatedProducts()"
|
||||||
|
[collections]="relatedCollections()"
|
||||||
(productSelected)="selectRelatedProduct($event)"
|
(productSelected)="selectRelatedProduct($event)"
|
||||||
(addToCart)="addRelatedToCart($event)"
|
(addToCart)="addRelatedToCart($event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { forkJoin, of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
import { Product } from '../../../../core/products/models/product-domain.model';
|
import { Product } from '../../../../core/products/models/product-domain.model';
|
||||||
import { EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../../../core/products/models/product-engagement.model';
|
import { EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../../../core/products/models/product-engagement.model';
|
||||||
import { ConfigService } from '../../../../core/config/config.service';
|
import { ConfigService } from '../../../../core/config/config.service';
|
||||||
@@ -13,14 +15,16 @@ import { CartService } from '../../../../services';
|
|||||||
import { LanguageService } from '../../../../services/language.service';
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
|
import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
|
||||||
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
||||||
|
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
||||||
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
|
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
|
||||||
|
import { ProductActionsComponent } from '../components/product-actions/product-actions.component';
|
||||||
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
|
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
|
||||||
import { ProductInformationComponent } from '../components/product-information/product-information.component';
|
import { ProductInformationComponent } from '../components/product-information/product-information.component';
|
||||||
import { ProductDescriptionComponent } from '../components/product-description/product-description.component';
|
import { ProductDescriptionComponent } from '../components/product-description/product-description.component';
|
||||||
import { ProductSpecificationsComponent } from '../components/product-specifications/product-specifications.component';
|
import { ProductSpecificationsComponent } from '../components/product-specifications/product-specifications.component';
|
||||||
import { ProductWarrantyComponent } from '../components/product-warranty/product-warranty.component';
|
import { ProductWarrantyComponent } from '../components/product-warranty/product-warranty.component';
|
||||||
import { RelatedProductsComponent } from '../components/related-products/related-products.component';
|
import { RelatedCollectionViewModel, RelatedProductsComponent } from '../components/related-products/related-products.component';
|
||||||
import { ProductVariantSelectorComponent } from '../components/variant-selector/variant-selector.component';
|
import { ProductVariantSelectorComponent, VariantOptionGroup } from '../components/variant-selector/variant-selector.component';
|
||||||
import { ProductTabItem, ProductTabKey, ProductTabsComponent } from '../engagement/components/product-tabs/product-tabs.component';
|
import { ProductTabItem, ProductTabKey, ProductTabsComponent } from '../engagement/components/product-tabs/product-tabs.component';
|
||||||
import { QuestionListComponent } from '../engagement/components/question-list/question-list.component';
|
import { QuestionListComponent } from '../engagement/components/question-list/question-list.component';
|
||||||
import { ReviewListComponent } from '../engagement/components/review-list/review-list.component';
|
import { ReviewListComponent } from '../engagement/components/review-list/review-list.component';
|
||||||
@@ -32,6 +36,7 @@ import { ReviewListComponent } from '../engagement/components/review-list/review
|
|||||||
RouterLink,
|
RouterLink,
|
||||||
LangRoutePipe,
|
LangRoutePipe,
|
||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
|
ProductActionsComponent,
|
||||||
ProductGalleryComponent,
|
ProductGalleryComponent,
|
||||||
ProductInformationComponent,
|
ProductInformationComponent,
|
||||||
ProductVariantSelectorComponent,
|
ProductVariantSelectorComponent,
|
||||||
@@ -58,13 +63,16 @@ export class ProductDetailsContainerComponent {
|
|||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
|
private readonly shareService = inject(ProductShareService);
|
||||||
|
|
||||||
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
|
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
|
||||||
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
||||||
|
|
||||||
readonly product = signal<Product | null>(null);
|
readonly product = signal<Product | null>(null);
|
||||||
readonly relatedProducts = signal<Product[]>([]);
|
readonly relatedProducts = signal<Product[]>([]);
|
||||||
|
readonly relatedCollections = signal<RelatedCollectionViewModel[]>([]);
|
||||||
readonly selectedPhotoIndex = signal(0);
|
readonly selectedPhotoIndex = signal(0);
|
||||||
|
readonly selectedOptions = signal<Record<string, string>>({});
|
||||||
readonly selectedColour = signal<string | null>(null);
|
readonly selectedColour = signal<string | null>(null);
|
||||||
readonly selectedSize = signal<string | null>(null);
|
readonly selectedSize = signal<string | null>(null);
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
@@ -85,6 +93,19 @@ export class ProductDetailsContainerComponent {
|
|||||||
|
|
||||||
readonly reviewsPageSize = computed(() => this.productPageConfigState().reviews.pageSize ?? 5);
|
readonly reviewsPageSize = computed(() => this.productPageConfigState().reviews.pageSize ?? 5);
|
||||||
readonly questionsPageSize = computed(() => this.productPageConfigState().questions.pageSize ?? 5);
|
readonly questionsPageSize = computed(() => this.productPageConfigState().questions.pageSize ?? 5);
|
||||||
|
readonly reviewsMode = computed(() => this.productPageConfigState().reviews.mode ?? 'pages');
|
||||||
|
|
||||||
|
readonly actionsConfig = computed(() => {
|
||||||
|
const config = this.productPageConfigState().actions;
|
||||||
|
return {
|
||||||
|
addToCart: config.enabled !== false && config.addToCart !== false,
|
||||||
|
buyNow: config.enabled !== false && config.buyNow !== false,
|
||||||
|
wishlist: config.enabled !== false && config.wishlist !== false,
|
||||||
|
compare: config.enabled !== false && config.compare !== false,
|
||||||
|
share: config.enabled !== false && config.share !== false,
|
||||||
|
notifyMe: config.enabled !== false && config.notifyMe !== false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
readonly tabsEnabled = computed(() => this.productPageConfigState().tabs.enabled !== false);
|
readonly tabsEnabled = computed(() => this.productPageConfigState().tabs.enabled !== false);
|
||||||
readonly tabs = computed<ProductTabItem[]>(() => {
|
readonly tabs = computed<ProductTabItem[]>(() => {
|
||||||
@@ -122,12 +143,61 @@ export class ProductDetailsContainerComponent {
|
|||||||
if (!details?.length) return null;
|
if (!details?.length) return null;
|
||||||
const colour = this.selectedColour();
|
const colour = this.selectedColour();
|
||||||
const size = this.selectedSize();
|
const size = this.selectedSize();
|
||||||
|
const selectedOptions = this.selectedOptions();
|
||||||
return details.find(detail =>
|
return details.find(detail =>
|
||||||
(!colour || (detail.colour || detail.color) === colour) &&
|
(!colour || (detail.colour || detail.color) === colour) &&
|
||||||
(!size || detail.size === size)
|
(!size || detail.size === size) &&
|
||||||
|
Object.entries(selectedOptions)
|
||||||
|
.filter(([key]) => key !== 'colour' && key !== 'size')
|
||||||
|
.every(([key, value]) => {
|
||||||
|
const dynamicValue = (detail as any)[key];
|
||||||
|
return !value || dynamicValue == null || String(dynamicValue) === value;
|
||||||
|
})
|
||||||
) ?? null;
|
) ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly variantOptionGroups = computed<VariantOptionGroup[]>(() => {
|
||||||
|
const current = this.product();
|
||||||
|
if (!current) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.variantOptions?.length) {
|
||||||
|
return current.variantOptions.map(group => ({
|
||||||
|
key: group.key,
|
||||||
|
label: group.label || group.key,
|
||||||
|
options: group.options.map(option => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label || option.value,
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: VariantOptionGroup[] = [];
|
||||||
|
const colours = this.availableColours();
|
||||||
|
const sizes = this.availableSizes();
|
||||||
|
|
||||||
|
if (colours.length > 0) {
|
||||||
|
groups.push({ key: 'colour', label: 'itemDetail.colour', options: colours.map(value => ({ value })) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sizes.length > 0) {
|
||||||
|
groups.push({ key: 'size', label: 'itemDetail.size', options: sizes.map(value => ({ value })) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly isInWishlist = computed(() => {
|
||||||
|
const current = this.product();
|
||||||
|
return current ? this.uxFacade.isInWishlist(current.itemID) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly isInCompare = computed(() => {
|
||||||
|
const current = this.product();
|
||||||
|
return current ? this.uxFacade.isInCompare(current.itemID) : false;
|
||||||
|
});
|
||||||
|
|
||||||
readonly effectivePrice = computed(() => this.selectedDetail()?.price ?? this.product()?.price ?? 0);
|
readonly effectivePrice = computed(() => this.selectedDetail()?.price ?? this.product()?.price ?? 0);
|
||||||
readonly effectiveCurrency = computed(() => this.selectedDetail()?.currency ?? this.product()?.currency ?? '');
|
readonly effectiveCurrency = computed(() => this.selectedDetail()?.currency ?? this.product()?.currency ?? '');
|
||||||
readonly effectiveRemaining = computed(() => this.selectedDetail()?.remaining ?? this.product()?.quantity ?? null);
|
readonly effectiveRemaining = computed(() => this.selectedDetail()?.remaining ?? this.product()?.quantity ?? null);
|
||||||
@@ -167,7 +237,9 @@ export class ProductDetailsContainerComponent {
|
|||||||
this.missing.set(false);
|
this.missing.set(false);
|
||||||
this.product.set(null);
|
this.product.set(null);
|
||||||
this.relatedProducts.set([]);
|
this.relatedProducts.set([]);
|
||||||
|
this.relatedCollections.set([]);
|
||||||
this.selectedPhotoIndex.set(0);
|
this.selectedPhotoIndex.set(0);
|
||||||
|
this.selectedOptions.set({});
|
||||||
this.ratingSummary.set(null);
|
this.ratingSummary.set(null);
|
||||||
this.reviewsResult.set(null);
|
this.reviewsResult.set(null);
|
||||||
this.questionsResult.set(null);
|
this.questionsResult.set(null);
|
||||||
@@ -200,6 +272,7 @@ export class ProductDetailsContainerComponent {
|
|||||||
|
|
||||||
selectColour(colour: string): void {
|
selectColour(colour: string): void {
|
||||||
this.selectedColour.set(colour);
|
this.selectedColour.set(colour);
|
||||||
|
this.selectedOptions.update(state => ({ ...state, colour }));
|
||||||
const sizes = this.availableSizes();
|
const sizes = this.availableSizes();
|
||||||
if (sizes.length && this.selectedSize() && !sizes.includes(this.selectedSize()!)) {
|
if (sizes.length && this.selectedSize() && !sizes.includes(this.selectedSize()!)) {
|
||||||
this.selectedSize.set(sizes[0]);
|
this.selectedSize.set(sizes[0]);
|
||||||
@@ -208,6 +281,20 @@ export class ProductDetailsContainerComponent {
|
|||||||
|
|
||||||
selectSize(size: string): void {
|
selectSize(size: string): void {
|
||||||
this.selectedSize.set(size);
|
this.selectedSize.set(size);
|
||||||
|
this.selectedOptions.update(state => ({ ...state, size }));
|
||||||
|
}
|
||||||
|
|
||||||
|
selectVariantOption(payload: { groupKey: string; value: string }): void {
|
||||||
|
this.selectedOptions.update(state => ({ ...state, [payload.groupKey]: payload.value }));
|
||||||
|
|
||||||
|
if (payload.groupKey === 'colour') {
|
||||||
|
this.selectColour(payload.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.groupKey === 'size') {
|
||||||
|
this.selectSize(payload.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addToCart(): void {
|
addToCart(): void {
|
||||||
@@ -221,6 +308,49 @@ export class ProductDetailsContainerComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buyNow(): void {
|
||||||
|
this.addToCart();
|
||||||
|
this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleWishlist(): void {
|
||||||
|
const current = this.product();
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uxFacade.toggleWishlist(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCompare(): void {
|
||||||
|
const current = this.product();
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.uxFacade.isInCompare(current.itemID)) {
|
||||||
|
this.uxFacade.removeFromCompare(current.itemID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxItems = Math.max(1, this.userExperienceConfig().compare.maxItems);
|
||||||
|
this.uxFacade.addToCompare(current, maxItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
async shareProduct(): Promise<void> {
|
||||||
|
const current = this.product();
|
||||||
|
if (!current || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/${this.languageService.currentLanguage()}/product/${current.itemID}`;
|
||||||
|
await this.shareService.shareProduct(current, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyMe(): void {
|
||||||
|
this.toggleWishlist();
|
||||||
|
}
|
||||||
|
|
||||||
addRelatedToCart(payload: { product: Product; event: Event }): void {
|
addRelatedToCart(payload: { product: Product; event: Event }): void {
|
||||||
payload.event.preventDefault();
|
payload.event.preventDefault();
|
||||||
payload.event.stopPropagation();
|
payload.event.stopPropagation();
|
||||||
@@ -244,7 +374,7 @@ export class ProductDetailsContainerComponent {
|
|||||||
this.loadProduct(productId);
|
this.loadProduct(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadReviews(page: number): void {
|
loadReviews(page: number, append = false): void {
|
||||||
const current = this.product();
|
const current = this.product();
|
||||||
if (!current || this.productPageConfigState().reviews.enabled === false) {
|
if (!current || this.productPageConfigState().reviews.enabled === false) {
|
||||||
return;
|
return;
|
||||||
@@ -258,7 +388,18 @@ export class ProductDetailsContainerComponent {
|
|||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: result => {
|
next: result => {
|
||||||
|
if (append && this.reviewsMode() === 'load-more') {
|
||||||
|
const previous = this.reviewsResult();
|
||||||
|
const mergedItems = [...(previous?.items ?? []), ...result.items]
|
||||||
|
.filter((item, index, source) => source.findIndex(entry => entry.id === item.id) === index);
|
||||||
|
|
||||||
|
this.reviewsResult.set({
|
||||||
|
...result,
|
||||||
|
items: mergedItems,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
this.reviewsResult.set(result);
|
this.reviewsResult.set(result);
|
||||||
|
}
|
||||||
this.reviewsLoading.set(false);
|
this.reviewsLoading.set(false);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -305,7 +446,7 @@ export class ProductDetailsContainerComponent {
|
|||||||
next: () => {
|
next: () => {
|
||||||
this.reviewSubmitting.set(false);
|
this.reviewSubmitting.set(false);
|
||||||
this.loadRating(current.itemID);
|
this.loadRating(current.itemID);
|
||||||
this.loadReviews(this.reviewsResult()?.page ?? 1);
|
this.loadReviews(1, false);
|
||||||
},
|
},
|
||||||
error: () => this.reviewSubmitting.set(false)
|
error: () => this.reviewSubmitting.set(false)
|
||||||
});
|
});
|
||||||
@@ -338,16 +479,55 @@ export class ProductDetailsContainerComponent {
|
|||||||
if (details?.length) {
|
if (details?.length) {
|
||||||
this.selectedColour.set(details[0].colour || details[0].color || null);
|
this.selectedColour.set(details[0].colour || details[0].color || null);
|
||||||
this.selectedSize.set(details[0].size && details[0].size.toLowerCase() !== 'default' ? details[0].size : null);
|
this.selectedSize.set(details[0].size && details[0].size.toLowerCase() !== 'default' ? details[0].size : null);
|
||||||
|
this.selectedOptions.set({
|
||||||
|
colour: details[0].colour || details[0].color || '',
|
||||||
|
size: details[0].size && details[0].size.toLowerCase() !== 'default' ? details[0].size : ''
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selectedColour.set(product.colour ?? null);
|
this.selectedColour.set(product.colour ?? null);
|
||||||
this.selectedSize.set(product.size && product.size.toLowerCase() !== 'default' ? product.size : null);
|
this.selectedSize.set(product.size && product.size.toLowerCase() !== 'default' ? product.size : null);
|
||||||
|
this.selectedOptions.set({
|
||||||
|
colour: product.colour ?? '',
|
||||||
|
size: product.size && product.size.toLowerCase() !== 'default' ? product.size : ''
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadRelatedProducts(product: Product): void {
|
private loadRelatedProducts(product: Product): void {
|
||||||
if (this.productPageConfigState().relatedProducts.enabled === false) {
|
if (this.productPageConfigState().relatedProducts.enabled === false) {
|
||||||
this.relatedProducts.set([]);
|
this.relatedProducts.set([]);
|
||||||
|
this.relatedCollections.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.relatedCollections?.length) {
|
||||||
|
const language = this.languageService.currentLanguage();
|
||||||
|
const requests = product.relatedCollections.map(collection =>
|
||||||
|
forkJoin(
|
||||||
|
(collection.products ?? [])
|
||||||
|
.filter(productID => productID !== product.itemID)
|
||||||
|
.map(productID => this.productFacade.getProduct(productID).pipe(catchError(() => of(null))))
|
||||||
|
).pipe(
|
||||||
|
catchError(() => of([] as Array<Product | null>))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
forkJoin(requests)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe(result => {
|
||||||
|
const collections = result.map((products, index) => {
|
||||||
|
const config = product.relatedCollections![index];
|
||||||
|
return {
|
||||||
|
id: config.id,
|
||||||
|
title: config.titles?.[language] || config.title || this.translate.t('itemDetail.relatedProducts'),
|
||||||
|
products: products.filter((item): item is Product => !!item)
|
||||||
|
} as RelatedCollectionViewModel;
|
||||||
|
}).filter(collection => collection.products.length > 0);
|
||||||
|
|
||||||
|
this.relatedCollections.set(collections);
|
||||||
|
this.relatedProducts.set(collections[0]?.products ?? []);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,8 +537,19 @@ export class ProductDetailsContainerComponent {
|
|||||||
count: 8,
|
count: 8,
|
||||||
}).pipe(takeUntilDestroyed(this.destroyRef))
|
}).pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: result => this.relatedProducts.set(result.items.filter(item => item.itemID !== product.itemID)),
|
next: result => {
|
||||||
error: () => this.relatedProducts.set([]),
|
const products = result.items.filter(item => item.itemID !== product.itemID);
|
||||||
|
this.relatedProducts.set(products);
|
||||||
|
this.relatedCollections.set([{
|
||||||
|
id: 'default-related',
|
||||||
|
title: this.translate.t('itemDetail.relatedProducts'),
|
||||||
|
products
|
||||||
|
}]);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.relatedProducts.set([]);
|
||||||
|
this.relatedCollections.set([]);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +559,7 @@ export class ProductDetailsContainerComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.productPageConfigState().reviews.enabled !== false) {
|
if (this.productPageConfigState().reviews.enabled !== false) {
|
||||||
this.loadReviews(1);
|
this.loadReviews(1, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.productPageConfigState().questions.enabled !== false) {
|
if (this.productPageConfigState().questions.enabled !== false) {
|
||||||
@@ -466,6 +657,10 @@ export class ProductDetailsContainerComponent {
|
|||||||
relatedProducts: {
|
relatedProducts: {
|
||||||
...DEFAULT_PRODUCT_PAGE_CONFIG.relatedProducts,
|
...DEFAULT_PRODUCT_PAGE_CONFIG.relatedProducts,
|
||||||
...(bootstrapConfig.relatedProducts ?? {})
|
...(bootstrapConfig.relatedProducts ?? {})
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
...DEFAULT_PRODUCT_PAGE_CONFIG.actions,
|
||||||
|
...(bootstrapConfig.actions ?? {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<section class="question-list section">
|
<section class="question-list section">
|
||||||
|
@if (allowSubmission) {
|
||||||
<button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())">
|
<button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())">
|
||||||
@if (askFormVisible()) { {{ 'productDetails.hideQuestionForm' | translate }} } @else { {{ 'productDetails.askQuestion' | translate }} }
|
@if (askFormVisible()) { {{ 'productDetails.hideQuestionForm' | translate }} } @else { {{ 'productDetails.askQuestion' | translate }} }
|
||||||
</button>
|
</button>
|
||||||
|
} @else {
|
||||||
|
<p class="submission-disabled">{{ 'productDetails.questionsSubmissionFuture' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
@if (askFormVisible()) {
|
@if (allowSubmission && askFormVisible()) {
|
||||||
<app-question-form [submitting]="submitting" (submitQuestion)="onSubmit($event)" />
|
<app-question-form [submitting]="submitting" (submitQuestion)="onSubmit($event)" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.submission-disabled {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.pager {
|
.pager {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export class QuestionListComponent {
|
|||||||
@Input() result: EngagementListResult<Question> | null = null;
|
@Input() result: EngagementListResult<Question> | null = null;
|
||||||
@Input() loading = false;
|
@Input() loading = false;
|
||||||
@Input() submitting = false;
|
@Input() submitting = false;
|
||||||
|
@Input() allowSubmission = true;
|
||||||
|
|
||||||
@Output() pageChange = new EventEmitter<number>();
|
@Output() pageChange = new EventEmitter<number>();
|
||||||
@Output() submitQuestion = new EventEmitter<SubmitQuestionInput>();
|
@Output() submitQuestion = new EventEmitter<SubmitQuestionInput>();
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
<form class="review-form card" (ngSubmit)="onSubmit()">
|
<form class="review-form card" (ngSubmit)="onSubmit()">
|
||||||
<h3>{{ 'productDetails.reviewFormTitle' | translate }}</h3>
|
<h3>{{ 'productDetails.reviewFormTitle' | translate }}</h3>
|
||||||
|
|
||||||
|
@if (submittedSuccessfully) {
|
||||||
|
<p class="success">{{ 'productDetails.reviewSuccess' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
<label>{{ 'productDetails.reviewRatingLabel' | translate }}</label>
|
<label>{{ 'productDetails.reviewRatingLabel' | translate }}</label>
|
||||||
<app-star-selector [(rating)]="rating" />
|
<app-star-selector [rating]="rating" (ratingChange)="updateRating($event)" />
|
||||||
|
@if (showRatingError) {
|
||||||
|
<p class="error">{{ 'productDetails.reviewRatingRequired' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
<label for="review-title">{{ 'productDetails.reviewTitleLabel' | translate }}</label>
|
<label for="review-title">{{ 'productDetails.reviewTitleLabel' | translate }}</label>
|
||||||
<input id="review-title" name="title" [(ngModel)]="title" [placeholder]="'productDetails.reviewTitlePlaceholder' | translate" maxlength="120" />
|
<input id="review-title" name="title" [(ngModel)]="title" [placeholder]="'productDetails.reviewTitlePlaceholder' | translate" maxlength="120" (input)="hideSuccess()" />
|
||||||
|
|
||||||
<label for="review-text">{{ 'productDetails.reviewTextLabel' | translate }}</label>
|
<label for="review-text">{{ 'productDetails.reviewTextLabel' | translate }}</label>
|
||||||
<textarea id="review-text" name="text" [(ngModel)]="text" rows="4" [placeholder]="'productDetails.reviewTextPlaceholder' | translate"></textarea>
|
<textarea id="review-text" name="text" [(ngModel)]="text" rows="4" [placeholder]="'productDetails.reviewTextPlaceholder' | translate" (input)="hideSuccess()"></textarea>
|
||||||
|
@if (showTextError) {
|
||||||
|
<p class="error">{{ 'productDetails.reviewTextRequired' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
<label class="check">
|
<label class="check">
|
||||||
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" />
|
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" (change)="hideSuccess()" />
|
||||||
<span>{{ 'productDetails.reviewAnonymous' | translate }}</span>
|
<span>{{ 'productDetails.reviewAnonymous' | translate }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ textarea {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success,
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #166534;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ export class ReviewFormComponent {
|
|||||||
title = '';
|
title = '';
|
||||||
text = '';
|
text = '';
|
||||||
anonymous = false;
|
anonymous = false;
|
||||||
|
attemptedSubmit = false;
|
||||||
|
submittedSuccessfully = false;
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
|
this.attemptedSubmit = true;
|
||||||
const payload: SubmitReviewInput = {
|
const payload: SubmitReviewInput = {
|
||||||
rating: this.rating,
|
rating: this.rating,
|
||||||
title: this.title,
|
title: this.title,
|
||||||
@@ -34,13 +37,32 @@ export class ReviewFormComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.submitReview.emit(payload);
|
this.submitReview.emit(payload);
|
||||||
|
this.submittedSuccessfully = true;
|
||||||
this.reset();
|
this.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateRating(value: number): void {
|
||||||
|
this.rating = value;
|
||||||
|
this.submittedSuccessfully = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSuccess(): void {
|
||||||
|
this.submittedSuccessfully = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get showRatingError(): boolean {
|
||||||
|
return this.attemptedSubmit && !this.rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
get showTextError(): boolean {
|
||||||
|
return this.attemptedSubmit && !this.text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private reset(): void {
|
private reset(): void {
|
||||||
this.rating = 0;
|
this.rating = 0;
|
||||||
this.title = '';
|
this.title = '';
|
||||||
this.text = '';
|
this.text = '';
|
||||||
this.anonymous = false;
|
this.anonymous = false;
|
||||||
|
this.attemptedSubmit = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,20 @@
|
|||||||
<p class="empty">{{ 'productDetails.reviewsEmpty' | translate }}</p>
|
<p class="empty">{{ 'productDetails.reviewsEmpty' | translate }}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (result && totalPages > 1) {
|
@if (result && totalPages > 1 && mode === 'pages') {
|
||||||
<div class="pager">
|
<div class="pager">
|
||||||
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">{{ 'productDetails.previous' | translate }}</button>
|
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">{{ 'productDetails.previous' | translate }}</button>
|
||||||
<span>{{ 'productDetails.pageOf' | translate:{ page: result.page, total: totalPages } }}</span>
|
<span>{{ 'productDetails.pageOf' | translate:{ page: result.page, total: totalPages } }}</span>
|
||||||
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">{{ 'productDetails.next' | translate }}</button>
|
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">{{ 'productDetails.next' | translate }}</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (result && mode === 'load-more' && result.items.length < result.total) {
|
||||||
|
<div class="load-more-wrap">
|
||||||
|
<button type="button" class="load-more" (click)="onLoadMore()" [disabled]="loading">
|
||||||
|
{{ 'productDetails.loadMore' | translate }}
|
||||||
|
</button>
|
||||||
|
<small>{{ 'productDetails.showingOf' | translate:{ shown: result.items.length, total: result.total } }}</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -35,6 +35,27 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-more-wrap {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--primary-color);
|
||||||
|
padding: 0 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-wrap small {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.skeleton-list {
|
.skeleton-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ export class ReviewListComponent {
|
|||||||
@Input() loading = false;
|
@Input() loading = false;
|
||||||
@Input() submitting = false;
|
@Input() submitting = false;
|
||||||
@Input() showSummary = true;
|
@Input() showSummary = true;
|
||||||
|
@Input() mode: 'pages' | 'load-more' = 'pages';
|
||||||
|
|
||||||
@Output() pageChange = new EventEmitter<number>();
|
@Output() pageChange = new EventEmitter<number>();
|
||||||
|
@Output() loadMore = new EventEmitter<number>();
|
||||||
@Output() submitReview = new EventEmitter<SubmitReviewInput>();
|
@Output() submitReview = new EventEmitter<SubmitReviewInput>();
|
||||||
|
|
||||||
get totalPages(): number {
|
get totalPages(): number {
|
||||||
@@ -45,4 +47,12 @@ export class ReviewListComponent {
|
|||||||
|
|
||||||
this.pageChange.emit(this.result.page + 1);
|
this.pageChange.emit(this.result.page + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onLoadMore(): void {
|
||||||
|
if (!this.result || this.result.items.length >= this.result.total || this.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadMore.emit((this.result.page ?? 1) + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,8 +267,27 @@ export const en: Translations = {
|
|||||||
reviewTextPlaceholder: 'Describe your experience',
|
reviewTextPlaceholder: 'Describe your experience',
|
||||||
reviewAnonymous: 'Submit anonymously',
|
reviewAnonymous: 'Submit anonymously',
|
||||||
reviewUploadPlaceholder: 'Photo upload is planned for a future sprint.',
|
reviewUploadPlaceholder: 'Photo upload is planned for a future sprint.',
|
||||||
|
reviewRatingRequired: 'Please select a rating.',
|
||||||
|
reviewTextRequired: 'Please add your review text.',
|
||||||
|
reviewSuccess: 'Review submitted successfully.',
|
||||||
submitReview: 'Submit review',
|
submitReview: 'Submit review',
|
||||||
submitting: 'Submitting...',
|
submitting: 'Submitting...',
|
||||||
|
loadMore: 'Load more',
|
||||||
|
showingOf: 'Showing {{shown}} of {{total}}',
|
||||||
|
questionsSubmissionFuture: 'Question submission can be enabled later without component changes.',
|
||||||
|
specificationsGeneral: 'General',
|
||||||
|
actionsAria: 'Product actions',
|
||||||
|
buyNow: 'Buy now',
|
||||||
|
wishlist: 'Wishlist',
|
||||||
|
compare: 'Compare',
|
||||||
|
share: 'Share',
|
||||||
|
notifyMe: 'Notify me',
|
||||||
|
zoom: 'Zoom',
|
||||||
|
fullscreen: 'Fullscreen',
|
||||||
|
pdfDocument: 'Product PDF document',
|
||||||
|
manualDocument: 'Product manual',
|
||||||
|
warrantyDocument: 'Warranty document',
|
||||||
|
openDocument: 'Open document',
|
||||||
specificationsEmpty: 'No specifications provided yet.',
|
specificationsEmpty: 'No specifications provided yet.',
|
||||||
warrantyTitle: 'Warranty and returns',
|
warrantyTitle: 'Warranty and returns',
|
||||||
warrantyItem1: 'Warranty terms are provided by the seller and local law.',
|
warrantyItem1: 'Warranty terms are provided by the seller and local law.',
|
||||||
|
|||||||
@@ -267,8 +267,27 @@ export const hy: Translations = {
|
|||||||
reviewTextPlaceholder: 'Նկարագրեք ձեր փորձը',
|
reviewTextPlaceholder: 'Նկարագրեք ձեր փորձը',
|
||||||
reviewAnonymous: 'Ուղարկել անանուն',
|
reviewAnonymous: 'Ուղարկել անանուն',
|
||||||
reviewUploadPlaceholder: 'Լուսանկար վերբեռնելը նախատեսված է հաջորդ սպրինտում։',
|
reviewUploadPlaceholder: 'Լուսանկար վերբեռնելը նախատեսված է հաջորդ սպրինտում։',
|
||||||
|
reviewRatingRequired: 'Խնդրում ենք ընտրել գնահատական։',
|
||||||
|
reviewTextRequired: 'Խնդրում ենք գրել կարծիք։',
|
||||||
|
reviewSuccess: 'Կարծիքը հաջողությամբ ուղարկվեց։',
|
||||||
submitReview: 'Ուղարկել կարծիքը',
|
submitReview: 'Ուղարկել կարծիքը',
|
||||||
submitting: 'Ուղարկվում է...',
|
submitting: 'Ուղարկվում է...',
|
||||||
|
loadMore: 'Բեռնել ավելին',
|
||||||
|
showingOf: 'Ցուցադրվում է {{shown}} / {{total}}',
|
||||||
|
questionsSubmissionFuture: 'Հարցի ուղարկումը կարելի է միացնել ավելի ուշ՝ առանց բաղադրիչի փոփոխության։',
|
||||||
|
specificationsGeneral: 'Ընդհանուր',
|
||||||
|
actionsAria: 'Ապրանքի գործողություններ',
|
||||||
|
buyNow: 'Գնել հիմա',
|
||||||
|
wishlist: 'Նախընտրածներ',
|
||||||
|
compare: 'Համեմատել',
|
||||||
|
share: 'Կիսվել',
|
||||||
|
notifyMe: 'Ծանուցել ինձ',
|
||||||
|
zoom: 'Մեծացնել',
|
||||||
|
fullscreen: 'Ամբողջ էկրան',
|
||||||
|
pdfDocument: 'Ապրանքի PDF փաստաթուղթ',
|
||||||
|
manualDocument: 'Ապրանքի ուղեցույց',
|
||||||
|
warrantyDocument: 'Երաշխիքի փաստաթուղթ',
|
||||||
|
openDocument: 'Բացել փաստաթուղթը',
|
||||||
specificationsEmpty: 'Բնութագրերը դեռ հասանելի չեն։',
|
specificationsEmpty: 'Բնութագրերը դեռ հասանելի չեն։',
|
||||||
warrantyTitle: 'Երաշխիք և վերադարձ',
|
warrantyTitle: 'Երաշխիք և վերադարձ',
|
||||||
warrantyItem1: 'Երաշխիքի պայմանները սահմանվում են վաճառողի և տեղական օրենքներով։',
|
warrantyItem1: 'Երաշխիքի պայմանները սահմանվում են վաճառողի և տեղական օրենքներով։',
|
||||||
|
|||||||
@@ -267,8 +267,27 @@ export const ru: Translations = {
|
|||||||
reviewTextPlaceholder: 'Опишите ваш опыт использования',
|
reviewTextPlaceholder: 'Опишите ваш опыт использования',
|
||||||
reviewAnonymous: 'Отправить анонимно',
|
reviewAnonymous: 'Отправить анонимно',
|
||||||
reviewUploadPlaceholder: 'Загрузка фото запланирована в следующем спринте.',
|
reviewUploadPlaceholder: 'Загрузка фото запланирована в следующем спринте.',
|
||||||
|
reviewRatingRequired: 'Выберите оценку.',
|
||||||
|
reviewTextRequired: 'Добавьте текст отзыва.',
|
||||||
|
reviewSuccess: 'Отзыв успешно отправлен.',
|
||||||
submitReview: 'Отправить отзыв',
|
submitReview: 'Отправить отзыв',
|
||||||
submitting: 'Отправка...',
|
submitting: 'Отправка...',
|
||||||
|
loadMore: 'Показать еще',
|
||||||
|
showingOf: 'Показано {{shown}} из {{total}}',
|
||||||
|
questionsSubmissionFuture: 'Отправку вопросов можно включить позже без изменения компонента.',
|
||||||
|
specificationsGeneral: 'Общие',
|
||||||
|
actionsAria: 'Действия с товаром',
|
||||||
|
buyNow: 'Купить сейчас',
|
||||||
|
wishlist: 'В избранное',
|
||||||
|
compare: 'Сравнить',
|
||||||
|
share: 'Поделиться',
|
||||||
|
notifyMe: 'Сообщить о наличии',
|
||||||
|
zoom: 'Увеличить',
|
||||||
|
fullscreen: 'Полный экран',
|
||||||
|
pdfDocument: 'PDF документ товара',
|
||||||
|
manualDocument: 'Инструкция товара',
|
||||||
|
warrantyDocument: 'Гарантийный документ',
|
||||||
|
openDocument: 'Открыть документ',
|
||||||
specificationsEmpty: 'Характеристики пока не указаны.',
|
specificationsEmpty: 'Характеристики пока не указаны.',
|
||||||
warrantyTitle: 'Гарантия и возврат',
|
warrantyTitle: 'Гарантия и возврат',
|
||||||
warrantyItem1: 'Условия гарантии определяются продавцом и местным законодательством.',
|
warrantyItem1: 'Условия гарантии определяются продавцом и местным законодательством.',
|
||||||
|
|||||||
@@ -265,8 +265,27 @@ export interface Translations {
|
|||||||
reviewTextPlaceholder: string;
|
reviewTextPlaceholder: string;
|
||||||
reviewAnonymous: string;
|
reviewAnonymous: string;
|
||||||
reviewUploadPlaceholder: string;
|
reviewUploadPlaceholder: string;
|
||||||
|
reviewRatingRequired: string;
|
||||||
|
reviewTextRequired: string;
|
||||||
|
reviewSuccess: string;
|
||||||
submitReview: string;
|
submitReview: string;
|
||||||
submitting: string;
|
submitting: string;
|
||||||
|
loadMore: string;
|
||||||
|
showingOf: string;
|
||||||
|
questionsSubmissionFuture: string;
|
||||||
|
specificationsGeneral: string;
|
||||||
|
actionsAria: string;
|
||||||
|
buyNow: string;
|
||||||
|
wishlist: string;
|
||||||
|
compare: string;
|
||||||
|
share: string;
|
||||||
|
notifyMe: string;
|
||||||
|
zoom: string;
|
||||||
|
fullscreen: string;
|
||||||
|
pdfDocument: string;
|
||||||
|
manualDocument: string;
|
||||||
|
warrantyDocument: string;
|
||||||
|
openDocument: string;
|
||||||
specificationsEmpty: string;
|
specificationsEmpty: string;
|
||||||
warrantyTitle: string;
|
warrantyTitle: string;
|
||||||
warrantyItem1: string;
|
warrantyItem1: string;
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ interface Photo {
|
|||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProductMediaType = 'image' | 'video' | 'pdf' | 'manual' | 'warranty';
|
||||||
|
|
||||||
|
export interface ProductMedia {
|
||||||
|
id?: string;
|
||||||
|
type: ProductMediaType;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
alt?: string;
|
||||||
|
title?: string;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DescriptionField {
|
export interface DescriptionField {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -59,6 +71,47 @@ interface ItemAttribute {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LocalizedValue {
|
||||||
|
[language: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSpecificationField {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
label?: string;
|
||||||
|
labels?: LocalizedValue;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSpecificationGroup {
|
||||||
|
id?: string;
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
labels?: LocalizedValue;
|
||||||
|
attributes: ProductSpecificationField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductVariantOption {
|
||||||
|
value: string;
|
||||||
|
label?: string;
|
||||||
|
labels?: LocalizedValue;
|
||||||
|
available?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductVariantOptionGroup {
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
labels?: LocalizedValue;
|
||||||
|
options: ProductVariantOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelatedProductCollection {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
titles?: LocalizedValue;
|
||||||
|
products: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeliveryOption {
|
export interface DeliveryOption {
|
||||||
deliveryPrice: number;
|
deliveryPrice: number;
|
||||||
deliveryPlace: string;
|
deliveryPlace: string;
|
||||||
@@ -104,6 +157,10 @@ export interface Item {
|
|||||||
names?: ItemName[];
|
names?: ItemName[];
|
||||||
descriptions?: ItemDescription[];
|
descriptions?: ItemDescription[];
|
||||||
attributes?: ItemAttribute[];
|
attributes?: ItemAttribute[];
|
||||||
|
media?: ProductMedia[];
|
||||||
|
specificationGroups?: ProductSpecificationGroup[];
|
||||||
|
variantOptions?: ProductVariantOptionGroup[];
|
||||||
|
relatedCollections?: RelatedProductCollection[];
|
||||||
|
|
||||||
// BackOffice API fields
|
// BackOffice API fields
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@@ -98,6 +98,15 @@ export class ApiService {
|
|||||||
return c.startsWith('0x') ? '#' + c.slice(2) : c;
|
return c.startsWith('0x') ? '#' + c.slice(2) : c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeMediaType(type: unknown): 'image' | 'video' | 'pdf' | 'manual' | 'warranty' {
|
||||||
|
const value = String(type ?? '').toLowerCase();
|
||||||
|
if (value === 'video') return 'video';
|
||||||
|
if (value === 'pdf') return 'pdf';
|
||||||
|
if (value === 'manual') return 'manual';
|
||||||
|
if (value === 'warranty') return 'warranty';
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeDeliveryData(
|
private normalizeDeliveryData(
|
||||||
raw: any,
|
raw: any,
|
||||||
legacyDeliveryPrice?: number
|
legacyDeliveryPrice?: number
|
||||||
@@ -256,6 +265,29 @@ export class ApiService {
|
|||||||
item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u))
|
item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u))
|
||||||
|| item.photos?.map((p: any) => p.url) || [];
|
|| item.photos?.map((p: any) => p.url) || [];
|
||||||
|
|
||||||
|
const rawMedia = Array.isArray(raw.media) ? raw.media : [];
|
||||||
|
const mediaFromPhotos = (item.photos ?? []).map((photo: any, index: number) => ({
|
||||||
|
id: photo.id ?? `photo-${item.itemID}-${index}`,
|
||||||
|
type: this.normalizeMediaType(photo.type ?? (photo.video ? 'video' : 'image')),
|
||||||
|
url: this.resolveImageUrl(photo.url),
|
||||||
|
thumbnailUrl: photo.thumbnailUrl ? this.resolveImageUrl(photo.thumbnailUrl) : undefined,
|
||||||
|
alt: photo.alt,
|
||||||
|
title: photo.title,
|
||||||
|
labels: photo.labels
|
||||||
|
}));
|
||||||
|
|
||||||
|
item.media = (rawMedia.length > 0 ? rawMedia : mediaFromPhotos)
|
||||||
|
.map((entry: any, index: number) => ({
|
||||||
|
id: entry.id ?? `media-${item.itemID}-${index}`,
|
||||||
|
type: this.normalizeMediaType(entry.type),
|
||||||
|
url: this.resolveImageUrl(entry.url),
|
||||||
|
thumbnailUrl: entry.thumbnailUrl ? this.resolveImageUrl(entry.thumbnailUrl) : undefined,
|
||||||
|
alt: entry.alt,
|
||||||
|
title: entry.title,
|
||||||
|
labels: entry.labels,
|
||||||
|
}))
|
||||||
|
.filter((entry: any) => !!entry.url);
|
||||||
|
|
||||||
// Map backOffice description (key-value array) → legacy description string
|
// Map backOffice description (key-value array) → legacy description string
|
||||||
if (Array.isArray(raw.description)) {
|
if (Array.isArray(raw.description)) {
|
||||||
item.descriptionFields = raw.description;
|
item.descriptionFields = raw.description;
|
||||||
@@ -287,6 +319,51 @@ export class ApiService {
|
|||||||
// Preserve attributes from backend
|
// Preserve attributes from backend
|
||||||
item.attributes = raw.attributes || [];
|
item.attributes = raw.attributes || [];
|
||||||
|
|
||||||
|
item.specificationGroups = Array.isArray(raw.specificationGroups)
|
||||||
|
? raw.specificationGroups.map((group: any, groupIndex: number) => ({
|
||||||
|
id: group.id ?? `group-${groupIndex}`,
|
||||||
|
key: group.key ?? `group-${groupIndex}`,
|
||||||
|
label: group.label,
|
||||||
|
labels: group.labels,
|
||||||
|
attributes: Array.isArray(group.attributes)
|
||||||
|
? group.attributes.map((attribute: any, attributeIndex: number) => ({
|
||||||
|
key: attribute.key ?? `attribute-${attributeIndex}`,
|
||||||
|
value: String(attribute.value ?? ''),
|
||||||
|
label: attribute.label,
|
||||||
|
labels: attribute.labels,
|
||||||
|
unit: attribute.unit,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
item.variantOptions = Array.isArray(raw.variantOptions)
|
||||||
|
? raw.variantOptions.map((group: any, groupIndex: number) => ({
|
||||||
|
key: group.key ?? `variant-${groupIndex}`,
|
||||||
|
label: group.label,
|
||||||
|
labels: group.labels,
|
||||||
|
options: Array.isArray(group.options)
|
||||||
|
? group.options.map((option: any) => ({
|
||||||
|
value: String(option.value ?? ''),
|
||||||
|
label: option.label,
|
||||||
|
labels: option.labels,
|
||||||
|
available: option.available !== false,
|
||||||
|
})).filter((option: any) => option.value.length > 0)
|
||||||
|
: []
|
||||||
|
})).filter((group: any) => group.options.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
item.relatedCollections = Array.isArray(raw.relatedCollections)
|
||||||
|
? raw.relatedCollections.map((collection: any, collectionIndex: number) => ({
|
||||||
|
id: String(collection.id ?? `related-${collectionIndex}`),
|
||||||
|
title: String(collection.title ?? ''),
|
||||||
|
titles: collection.titles,
|
||||||
|
products: Array.isArray(collection.products)
|
||||||
|
? collection.products.map((productId: any) => Number(productId)).filter((productId: number) => Number.isFinite(productId))
|
||||||
|
: []
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
// Preserve colour & size (only if not already set from itemDetails)
|
// Preserve colour & size (only if not already set from itemDetails)
|
||||||
if (!item.colour) item.colour = this.normalizeColor(raw.colour || '');
|
if (!item.colour) item.colour = this.normalizeColor(raw.colour || '');
|
||||||
if (!item.size) item.size = raw.size || '';
|
if (!item.size) item.size = raw.size || '';
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ export interface ProductRatingConfig extends ProductSectionConfig {}
|
|||||||
export interface ProductReviewsConfig extends ProductSectionConfig {
|
export interface ProductReviewsConfig extends ProductSectionConfig {
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
showSummary?: boolean;
|
showSummary?: boolean;
|
||||||
|
mode?: 'pages' | 'load-more';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductQuestionsConfig extends ProductSectionConfig {
|
export interface ProductQuestionsConfig extends ProductSectionConfig {
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
allowSubmission?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductTabsConfig extends ProductSectionConfig {
|
export interface ProductTabsConfig extends ProductSectionConfig {
|
||||||
@@ -19,12 +21,22 @@ export interface ProductTabsConfig extends ProductSectionConfig {
|
|||||||
|
|
||||||
export interface ProductRelatedConfig extends ProductSectionConfig {}
|
export interface ProductRelatedConfig extends ProductSectionConfig {}
|
||||||
|
|
||||||
|
export interface ProductActionsConfig extends ProductSectionConfig {
|
||||||
|
addToCart?: boolean;
|
||||||
|
buyNow?: boolean;
|
||||||
|
wishlist?: boolean;
|
||||||
|
compare?: boolean;
|
||||||
|
share?: boolean;
|
||||||
|
notifyMe?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProductPageConfig {
|
export interface ProductPageConfig {
|
||||||
rating?: ProductRatingConfig;
|
rating?: ProductRatingConfig;
|
||||||
reviews?: ProductReviewsConfig;
|
reviews?: ProductReviewsConfig;
|
||||||
questions?: ProductQuestionsConfig;
|
questions?: ProductQuestionsConfig;
|
||||||
tabs?: ProductTabsConfig;
|
tabs?: ProductTabsConfig;
|
||||||
relatedProducts?: ProductRelatedConfig;
|
relatedProducts?: ProductRelatedConfig;
|
||||||
|
actions?: ProductActionsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
|
export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
|
||||||
@@ -34,11 +46,13 @@ export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
|
|||||||
reviews: {
|
reviews: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
pageSize: 5,
|
pageSize: 5,
|
||||||
showSummary: true
|
showSummary: true,
|
||||||
|
mode: 'pages'
|
||||||
},
|
},
|
||||||
questions: {
|
questions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
pageSize: 5
|
pageSize: 5,
|
||||||
|
allowSubmission: true
|
||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -46,5 +60,14 @@ export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
|
|||||||
},
|
},
|
||||||
relatedProducts: {
|
relatedProducts: {
|
||||||
enabled: true
|
enabled: true
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
enabled: true,
|
||||||
|
addToCart: true,
|
||||||
|
buyNow: true,
|
||||||
|
wishlist: true,
|
||||||
|
compare: true,
|
||||||
|
share: true,
|
||||||
|
notifyMe: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user