From aed0a47388ade5c8b86941803d67fd4d9c30ef79 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 10 Jul 2026 13:10:35 +0400 Subject: [PATCH] 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. --- docs/Product-Details-Report.md | 115 ++++++++++ docs/platform/10-product-domain.md | 55 ++++- skills-lock.json | 12 + .../products/models/product-domain.model.ts | 11 + .../product-actions.component.html | 25 ++ .../product-actions.component.scss | 43 ++++ .../product-actions.component.ts | 41 ++++ .../product-gallery.component.html | 45 +++- .../product-gallery.component.scss | 64 +++++- .../product-gallery.component.ts | 82 ++++++- .../product-information.component.html | 8 +- .../product-information.component.ts | 1 + .../product-specifications.component.html | 23 +- .../product-specifications.component.scss | 12 + .../product-specifications.component.ts | 57 ++++- .../related-products.component.html | 34 +-- .../related-products.component.ts | 23 ++ .../variant-selector.component.html | 27 +-- .../variant-selector.component.ts | 67 +++++- .../product-details-container.component.html | 23 ++ .../product-details-container.component.ts | 213 +++++++++++++++++- .../question-list.component.html | 12 +- .../question-list.component.scss | 6 + .../question-list/question-list.component.ts | 1 + .../review-form/review-form.component.html | 18 +- .../review-form/review-form.component.scss | 15 ++ .../review-form/review-form.component.ts | 22 ++ .../review-list/review-list.component.html | 11 +- .../review-list/review-list.component.scss | 21 ++ .../review-list/review-list.component.ts | 10 + src/app/i18n/en.ts | 19 ++ src/app/i18n/hy.ts | 19 ++ src/app/i18n/ru.ts | 19 ++ src/app/i18n/translations.ts | 19 ++ src/app/models/item.model.ts | 57 +++++ src/app/services/api.service.ts | 77 +++++++ .../config/product-page-config.model.ts | 27 ++- 37 files changed, 1257 insertions(+), 77 deletions(-) create mode 100644 src/app/features/website/product/components/product-actions/product-actions.component.html create mode 100644 src/app/features/website/product/components/product-actions/product-actions.component.scss create mode 100644 src/app/features/website/product/components/product-actions/product-actions.component.ts diff --git a/docs/Product-Details-Report.md b/docs/Product-Details-Report.md index d9dcd5a..563ee71 100644 --- a/docs/Product-Details-Report.md +++ b/docs/Product-Details-Report.md @@ -155,3 +155,118 @@ npm run build ## Stop Point Product Details Module implementation is complete for Sprint 6. Stop here for approval before starting any further module work. + +## Sprint 11 - Product Experience 2.0 + +Sprint 11 extends Product Details UX with reusable modules and config-driven behavior. Architecture, Widget Manifest, Section Engine, bootstrap loading, authentication, and payment logic remain unchanged. + +### New Reusable Modules + +- Product Actions module + - `Add to Cart`, `Buy Now`, `Wishlist`, `Compare`, `Share`, `Notify Me` + - `productPage.actions` controls visibility of each action +- Product Gallery upgrade + - Media renderer by type (`image`, `video`, `pdf`, `manual`, `warranty`) + - Thumbnail strip + active media + - Mobile swipe support + - Zoom/fullscreen extension events (`zoomRequested`, `fullscreenRequested`) +- Product Specifications upgrade + - Grouped attributes via `specificationGroups` + - Translated labels via `labels` map + - Backward-compatible fallback to `descriptionFields` and `attributes` +- Variant Selector upgrade + - Dynamic option groups via `variantOptions` + - No hardcoded variant keys required + - Backward-compatible color/size fallback +- Reviews upgrade + - Existing pagination preserved + - Optional `load-more` mode added via config + - Review form adds explicit validation and success state +- Questions & Answers upgrade + - Existing list/answers/accepted-answer behavior preserved + - Submission can be toggled off while component remains future-ready +- Related Products upgrade + - Supports multiple backend-provided collections + - Falls back to previous single related collection behavior + +### Config Additions (backward-compatible) + +`productPage` supports new optional keys: + +```json +{ + "reviews": { + "mode": "pages" + }, + "questions": { + "allowSubmission": true + }, + "actions": { + "enabled": true, + "addToCart": true, + "buyNow": true, + "wishlist": true, + "compare": true, + "share": true, + "notifyMe": true + } +} +``` + +### Product JSON Extension Example + +```json +{ + "itemID": 7812, + "name": "Laptop Pro 14", + "media": [ + { "type": "image", "url": "https://cdn.example.com/items/7812/main.jpg" }, + { "type": "video", "url": "https://cdn.example.com/items/7812/demo.mp4" }, + { "type": "pdf", "url": "https://cdn.example.com/items/7812/spec.pdf" }, + { "type": "manual", "url": "https://cdn.example.com/items/7812/manual.pdf" }, + { "type": "warranty", "url": "https://cdn.example.com/items/7812/warranty.pdf" } + ], + "specificationGroups": [ + { + "key": "display", + "label": "Display", + "labels": { "en": "Display", "ru": "Экран", "hy": "Էկրան" }, + "attributes": [ + { "key": "size", "value": "14", "unit": "inch", "labels": { "en": "Size", "ru": "Диагональ", "hy": "Չափ" } }, + { "key": "resolution", "value": "2880x1800", "labels": { "en": "Resolution", "ru": "Разрешение", "hy": "Լուծաչափ" } } + ] + } + ], + "variantOptions": [ + { + "key": "storage", + "label": "Storage", + "options": [ + { "value": "256GB" }, + { "value": "512GB" } + ] + } + ], + "relatedCollections": [ + { + "id": "frequently-bought-together", + "title": "Frequently bought together", + "titles": { "ru": "Покупают вместе", "hy": "Հաճախ գնում են միասին" }, + "products": [9021, 9022, 9023] + }, + { + "id": "similar-products", + "title": "Similar products", + "products": [9030, 9031] + } + ] +} +``` + +### Extension Points + +- Gallery overlays can subscribe to `zoomRequested` and `fullscreenRequested` without changing gallery internals. +- Variant logic can add new option groups from backend by extending `variantOptions` only. +- Specifications can add locale labels without frontend refactor. +- Related collections can add campaign-specific blocks without changing section engine. +- Action visibility and review mode can be tuned from config without component rewrites. diff --git a/docs/platform/10-product-domain.md b/docs/platform/10-product-domain.md index ef4c684..cf931f5 100644 --- a/docs/platform/10-product-domain.md +++ b/docs/platform/10-product-domain.md @@ -24,6 +24,10 @@ - attributes - stockStatus - rating +- media +- specificationGroups +- variantOptions +- relatedCollections ## Product Engagement Models - RatingSummary @@ -41,6 +45,26 @@ - id, text, author, createdAt - isOfficialSeller, isAccepted +## Product Experience 2.0 Optional Contracts + +- media: `[{ type, url, thumbnailUrl?, alt?, title?, labels? }]` + - type: `image | video | pdf | manual | warranty` + - frontend renderer picks viewer by `type` +- specificationGroups: `[{ key, label?, labels?, attributes: [{ key, value, label?, labels?, unit? }] }]` + - supports grouped specifications and translated labels +- variantOptions: `[{ key, label?, labels?, options: [{ value, label?, labels?, available? }] }]` + - supports arbitrary variant groups (`color`, `size`, `storage`, etc.) +- relatedCollections: `[{ id, title, titles?, products: number[] }]` + - supports multiple related collections from backend + +## Product Page Config Extensions + +`productPage` optional config additions: + +- reviews.mode: `pages | load-more` +- questions.allowSubmission: `boolean` +- actions: `{ enabled, addToCart, buyNow, wishlist, compare, share, notifyMe }` + ## Строгие правила - Цена и валюта должны передаваться как валидная пара. - Скрытые товары не участвуют в публичных витринах. @@ -63,7 +87,35 @@ "images": [ { "url": "https://cdn.example.com/items/7812/main.jpg", "isMain": true } ], - "badges": ["featured", "new"] + "badges": ["featured", "new"], + "media": [ + { "type": "image", "url": "https://cdn.example.com/items/7812/main.jpg" }, + { "type": "video", "url": "https://cdn.example.com/items/7812/demo.mp4" }, + { "type": "pdf", "url": "https://cdn.example.com/items/7812/spec.pdf" } + ], + "specificationGroups": [ + { + "key": "display", + "labels": { "en": "Display", "ru": "Экран" }, + "attributes": [ + { "key": "size", "value": "14", "unit": "inch" }, + { "key": "resolution", "value": "2880x1800" } + ] + } + ], + "variantOptions": [ + { + "key": "storage", + "options": [{ "value": "256GB" }, { "value": "512GB" }] + } + ], + "relatedCollections": [ + { + "id": "similar-products", + "title": "Похожие товары", + "products": [9030, 9031] + } + ] } ``` @@ -76,3 +128,4 @@ - Возвращать актуальные цены и доступность. - Стабильно поддерживать идентификаторы товаров. - Предоставлять медиа и атрибуты в согласованном формате. +- Поддерживать обратную совместимость: новые поля опциональны, старые payload остаются валидными. diff --git a/skills-lock.json b/skills-lock.json index 3baa75e..e4c7371 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,12 @@ { "version": 1, "skills": { + "angular-developer": { + "source": "angular/skills", + "sourceType": "github", + "skillPath": "angular-developer/SKILL.md", + "computedHash": "62e087c9cf0dc17f4ca4fed9f451f65605f43e4427016eb799409d6da39a0a87" + }, "cavecrew": { "source": "JuliusBrussee/caveman", "sourceType": "github", @@ -42,6 +48,12 @@ "sourceType": "github", "skillPath": "skills/caveman-stats/SKILL.md", "computedHash": "331f720e2fa97b68cacdae44384878071e8cac6013479edea68f4c8eca308852" + }, + "design-taste-frontend": { + "source": "Leonxlnx/taste-skill", + "sourceType": "github", + "skillPath": "skills/taste-skill/SKILL.md", + "computedHash": "899b84384f74f540ea5284d9b2e9234e050998b42eacc805410b518d4226c0b3" } } } diff --git a/src/app/core/products/models/product-domain.model.ts b/src/app/core/products/models/product-domain.model.ts index fd9de08..84afd8d 100644 --- a/src/app/core/products/models/product-domain.model.ts +++ b/src/app/core/products/models/product-domain.model.ts @@ -36,3 +36,14 @@ export interface RelatedProductsQuery { categoryID?: number; count?: number; } + +export interface RelatedProductCollection { + id: string; + title: string; + titles?: Record; + productIDs: number[]; +} + +export interface ProductVariantSelection { + [groupKey: string]: string; +} diff --git a/src/app/features/website/product/components/product-actions/product-actions.component.html b/src/app/features/website/product/components/product-actions/product-actions.component.html new file mode 100644 index 0000000..c21d9bf --- /dev/null +++ b/src/app/features/website/product/components/product-actions/product-actions.component.html @@ -0,0 +1,25 @@ +
+ @if (config.addToCart) { + + } + + @if (config.buyNow) { + + } + + @if (config.wishlist) { + + } + + @if (config.compare) { + + } + + @if (config.share) { + + } + + @if (config.notifyMe && outOfStock) { + + } +
diff --git a/src/app/features/website/product/components/product-actions/product-actions.component.scss b/src/app/features/website/product/components/product-actions/product-actions.component.scss new file mode 100644 index 0000000..cf4e733 --- /dev/null +++ b/src/app/features/website/product/components/product-actions/product-actions.component.scss @@ -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%; + } +} diff --git a/src/app/features/website/product/components/product-actions/product-actions.component.ts b/src/app/features/website/product/components/product-actions/product-actions.component.ts new file mode 100644 index 0000000..4fc2701 --- /dev/null +++ b/src/app/features/website/product/components/product-actions/product-actions.component.ts @@ -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(); + @Output() buyNow = new EventEmitter(); + @Output() wishlist = new EventEmitter(); + @Output() compare = new EventEmitter(); + @Output() share = new EventEmitter(); + @Output() notifyMe = new EventEmitter(); +} diff --git a/src/app/features/website/product/components/product-gallery/product-gallery.component.html b/src/app/features/website/product/components/product-gallery/product-gallery.component.html index b018255..3b9101c 100644 --- a/src/app/features/website/product/components/product-gallery/product-gallery.component.html +++ b/src/app/features/website/product/components/product-gallery/product-gallery.component.html @@ -1,9 +1,38 @@ diff --git a/src/app/features/website/product/components/product-information/product-information.component.ts b/src/app/features/website/product/components/product-information/product-information.component.ts index f499c43..8bf0722 100644 --- a/src/app/features/website/product/components/product-information/product-information.component.ts +++ b/src/app/features/website/product/components/product-information/product-information.component.ts @@ -20,6 +20,7 @@ export class ProductInformationComponent { @Input() currency = ''; @Input() remaining: number | null = null; @Input() stockStatus = 'high'; + @Input() showAddToCart = true; @Output() addToCart = new EventEmitter(); diff --git a/src/app/features/website/product/components/product-specifications/product-specifications.component.html b/src/app/features/website/product/components/product-specifications/product-specifications.component.html index f28d575..53603b4 100644 --- a/src/app/features/website/product/components/product-specifications/product-specifications.component.html +++ b/src/app/features/website/product/components/product-specifications/product-specifications.component.html @@ -1,15 +1,20 @@

{{ 'itemDetail.specifications' | translate }}

- @if (descriptionFields.length > 0) { -
- @for (field of descriptionFields; track field.key) { -
-
{{ field.key }}
-
{{ field.value }}
-
- } -
+ @if (groups.length > 0) { + @for (group of groups; track group.key) { +
+

{{ group.label | translate }}

+
+ @for (field of group.fields; track field.key) { +
+
{{ field.label | translate }}
+
{{ field.value }}
+
+ } +
+
+ } } @else {

{{ 'productDetails.specificationsEmpty' | translate }}

} diff --git a/src/app/features/website/product/components/product-specifications/product-specifications.component.scss b/src/app/features/website/product/components/product-specifications/product-specifications.component.scss index 492ee94..25082cd 100644 --- a/src/app/features/website/product/components/product-specifications/product-specifications.component.scss +++ b/src/app/features/website/product/components/product-specifications/product-specifications.component.scss @@ -7,6 +7,18 @@ h3 { color: var(--text-primary); } +.spec-group { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +h4 { + margin: 0; + color: var(--text-primary); + font-size: 1rem; +} + dl { margin: 0; display: grid; diff --git a/src/app/features/website/product/components/product-specifications/product-specifications.component.ts b/src/app/features/website/product/components/product-specifications/product-specifications.component.ts index 22ed7b6..9d40a66 100644 --- a/src/app/features/website/product/components/product-specifications/product-specifications.component.ts +++ b/src/app/features/website/product/components/product-specifications/product-specifications.component.ts @@ -1,6 +1,19 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { Product } from '../../../../../core/products/models/product-domain.model'; import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { LanguageService } from '../../../../../services/language.service'; + +interface SpecificationViewField { + key: string; + label: string; + value: string; +} + +interface SpecificationViewGroup { + key: string; + label: string; + fields: SpecificationViewField[]; +} @Component({ selector: 'app-product-specifications', @@ -13,7 +26,47 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe'; export class ProductSpecificationsComponent { @Input({ required: true }) product!: Product; - get descriptionFields() { - return this.product.descriptionFields ?? []; + constructor(private readonly languageService: LanguageService) {} + + get groups(): SpecificationViewGroup[] { + const locale = this.languageService.currentLanguage(); + const groups = this.product.specificationGroups ?? []; + + if (groups.length > 0) { + return groups + .map(group => ({ + key: group.key, + label: group.labels?.[locale] || group.label || group.key, + fields: (group.attributes ?? []).map(attribute => ({ + key: attribute.key, + label: attribute.labels?.[locale] || attribute.label || attribute.key, + value: attribute.unit ? `${attribute.value} ${attribute.unit}` : attribute.value, + })) + })) + .filter(group => group.fields.length > 0); + } + + const fallbackFields = [ + ...(this.product.descriptionFields ?? []).map(field => ({ + key: field.key, + label: field.key, + value: field.value, + })), + ...(this.product.attributes ?? []).map(attribute => ({ + key: attribute.key, + label: attribute.key, + value: attribute.value, + })) + ]; + + if (fallbackFields.length === 0) { + return []; + } + + return [{ + key: 'general', + label: 'productDetails.specificationsGeneral', + fields: fallbackFields, + }]; } } diff --git a/src/app/features/website/product/components/related-products/related-products.component.html b/src/app/features/website/product/components/related-products/related-products.component.html index 593f890..a32d9ff 100644 --- a/src/app/features/website/product/components/related-products/related-products.component.html +++ b/src/app/features/website/product/components/related-products/related-products.component.html @@ -1,17 +1,19 @@ -@if (products.length > 0) { - +@if (normalizedCollections.length > 0) { + @for (collection of normalizedCollections; track collection.id) { + + } } diff --git a/src/app/features/website/product/components/related-products/related-products.component.ts b/src/app/features/website/product/components/related-products/related-products.component.ts index 8ae5860..0c5f8e2 100644 --- a/src/app/features/website/product/components/related-products/related-products.component.ts +++ b/src/app/features/website/product/components/related-products/related-products.component.ts @@ -5,6 +5,12 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { LanguageService } from '../../../../../services/language.service'; import { getTranslatedField, trackByItemId } from '../../../../../utils/item.utils'; +export interface RelatedCollectionViewModel { + id: string; + title: string; + products: Product[]; +} + @Component({ selector: 'app-related-products', standalone: true, @@ -15,6 +21,7 @@ import { getTranslatedField, trackByItemId } from '../../../../../utils/item.uti }) export class RelatedProductsComponent { @Input() products: Product[] = []; + @Input() collections: RelatedCollectionViewModel[] = []; @Output() productSelected = new EventEmitter(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @@ -23,6 +30,22 @@ export class RelatedProductsComponent { readonly trackByItemId = trackByItemId; + get normalizedCollections(): RelatedCollectionViewModel[] { + if (this.collections.length > 0) { + return this.collections.filter(collection => collection.products.length > 0); + } + + if (this.products.length === 0) { + return []; + } + + return [{ + id: 'default-related-products', + title: 'itemDetail.relatedProducts', + products: this.products, + }]; + } + productTitle(product: Product): string { return getTranslatedField(product, 'name', this.languageService.currentLanguage()); } diff --git a/src/app/features/website/product/components/variant-selector/variant-selector.component.html b/src/app/features/website/product/components/variant-selector/variant-selector.component.html index 2ee8a07..599aefd 100644 --- a/src/app/features/website/product/components/variant-selector/variant-selector.component.html +++ b/src/app/features/website/product/components/variant-selector/variant-selector.component.html @@ -1,31 +1,28 @@ @if (hasVariants) {
- @if (colours.length > 0) { + @for (group of normalizedGroups; track group.key) {
- {{ 'itemDetail.colour' | translate }} + {{ group.label | translate }}
- @for (colour of colours; track $index) { - + @for (option of group.options; track option.value) { + @if (group.key === 'colour') { + + } @else { + + } }
- } @else if (fallbackColour) { + } + + @if (normalizedGroups.length === 0 && fallbackColour) {
{{ 'itemDetail.colour' | translate }}
} - @if (sizes.length > 0) { -
- {{ 'itemDetail.size' | translate }} -
- @for (size of sizes; track $index) { - - } -
-
- } @else if (visibleFallbackSize) { + @if (normalizedGroups.length === 0 && visibleFallbackSize) {
{{ 'itemDetail.size' | translate }} {{ visibleFallbackSize }} diff --git a/src/app/features/website/product/components/variant-selector/variant-selector.component.ts b/src/app/features/website/product/components/variant-selector/variant-selector.component.ts index f8cb1f7..310ad68 100644 --- a/src/app/features/website/product/components/variant-selector/variant-selector.component.ts +++ b/src/app/features/website/product/components/variant-selector/variant-selector.component.ts @@ -1,6 +1,17 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +export interface VariantOption { + value: string; + label?: string; +} + +export interface VariantOptionGroup { + key: string; + label: string; + options: VariantOption[]; +} + @Component({ selector: 'app-product-variant-selector', standalone: true, @@ -10,6 +21,9 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe'; changeDetection: ChangeDetectionStrategy.OnPush }) export class ProductVariantSelectorComponent { + @Input() optionGroups: VariantOptionGroup[] = []; + @Input() selectedOptions: Record = {}; + @Input() colours: string[] = []; @Input() sizes: string[] = []; @Input() selectedColour: string | null = null; @@ -17,11 +31,62 @@ export class ProductVariantSelectorComponent { @Input() fallbackColour: string | null = null; @Input() fallbackSize: string | null = null; + @Output() optionSelected = new EventEmitter<{ groupKey: string; value: string }>(); @Output() colourSelected = new EventEmitter(); @Output() sizeSelected = new EventEmitter(); 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 { diff --git a/src/app/features/website/product/containers/product-details-container.component.html b/src/app/features/website/product/containers/product-details-container.component.html index 6240bb2..426c58b 100644 --- a/src/app/features/website/product/containers/product-details-container.component.html +++ b/src/app/features/website/product/containers/product-details-container.component.html @@ -40,16 +40,32 @@ [currency]="effectiveCurrency()" [remaining]="effectiveRemaining()" [stockStatus]="stockStatus()" + [showAddToCart]="false" (addToCart)="addToCart()" /> + + @@ -82,7 +98,9 @@ [loading]="reviewsLoading() || ratingLoading()" [submitting]="reviewSubmitting()" [showSummary]="productPageConfigState().reviews.showSummary !== false" + [mode]="reviewsMode()" (pageChange)="loadReviews($event)" + (loadMore)="loadReviews($event, true)" (submitReview)="submitReview($event)" /> } @@ -91,6 +109,7 @@ [result]="questionsResult()" [loading]="questionsLoading()" [submitting]="questionSubmitting()" + [allowSubmission]="productPageConfigState().questions.allowSubmission !== false" (pageChange)="loadQuestions($event)" (submitQuestion)="submitQuestion($event)" /> } @@ -122,7 +141,9 @@ [loading]="reviewsLoading() || ratingLoading()" [submitting]="reviewSubmitting()" [showSummary]="productPageConfigState().reviews.showSummary !== false" + [mode]="reviewsMode()" (pageChange)="loadReviews($event)" + (loadMore)="loadReviews($event, true)" (submitReview)="submitReview($event)" /> } @@ -131,6 +152,7 @@ [result]="questionsResult()" [loading]="questionsLoading()" [submitting]="questionSubmitting()" + [allowSubmission]="productPageConfigState().questions.allowSubmission !== false" (pageChange)="loadQuestions($event)" (submitQuestion)="submitQuestion($event)" /> } @@ -148,6 +170,7 @@ @if (productPageConfigState().relatedProducts.enabled !== false) { diff --git a/src/app/features/website/product/containers/product-details-container.component.ts b/src/app/features/website/product/containers/product-details-container.component.ts index b5e001c..5c4a4a5 100644 --- a/src/app/features/website/product/containers/product-details-container.component.ts +++ b/src/app/features/website/product/containers/product-details-container.component.ts @@ -1,6 +1,8 @@ import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import { Product } from '../../../../core/products/models/product-domain.model'; import { EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../../../core/products/models/product-engagement.model'; import { ConfigService } from '../../../../core/config/config.service'; @@ -13,14 +15,16 @@ import { CartService } from '../../../../services'; import { LanguageService } from '../../../../services/language.service'; import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config'; import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils'; +import { ProductShareService } from '../../user-experience/services/product-share.service'; import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component'; +import { ProductActionsComponent } from '../components/product-actions/product-actions.component'; import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component'; import { ProductInformationComponent } from '../components/product-information/product-information.component'; import { ProductDescriptionComponent } from '../components/product-description/product-description.component'; import { ProductSpecificationsComponent } from '../components/product-specifications/product-specifications.component'; import { ProductWarrantyComponent } from '../components/product-warranty/product-warranty.component'; -import { RelatedProductsComponent } from '../components/related-products/related-products.component'; -import { ProductVariantSelectorComponent } from '../components/variant-selector/variant-selector.component'; +import { RelatedCollectionViewModel, RelatedProductsComponent } from '../components/related-products/related-products.component'; +import { ProductVariantSelectorComponent, VariantOptionGroup } from '../components/variant-selector/variant-selector.component'; import { ProductTabItem, ProductTabKey, ProductTabsComponent } from '../engagement/components/product-tabs/product-tabs.component'; import { QuestionListComponent } from '../engagement/components/question-list/question-list.component'; import { ReviewListComponent } from '../engagement/components/review-list/review-list.component'; @@ -32,6 +36,7 @@ import { ReviewListComponent } from '../engagement/components/review-list/review RouterLink, LangRoutePipe, TranslatePipe, + ProductActionsComponent, ProductGalleryComponent, ProductInformationComponent, ProductVariantSelectorComponent, @@ -58,13 +63,16 @@ export class ProductDetailsContainerComponent { private readonly cartService = inject(CartService); private readonly languageService = inject(LanguageService); private readonly translate = inject(TranslateService); + private readonly shareService = inject(ProductShareService); readonly productPageConfigState = signal>(this.resolveProductPageConfig()); readonly userExperienceConfig = signal(this.resolveUserExperienceConfig()); readonly product = signal(null); readonly relatedProducts = signal([]); + readonly relatedCollections = signal([]); readonly selectedPhotoIndex = signal(0); + readonly selectedOptions = signal>({}); readonly selectedColour = signal(null); readonly selectedSize = signal(null); readonly loading = signal(true); @@ -85,6 +93,19 @@ export class ProductDetailsContainerComponent { readonly reviewsPageSize = computed(() => this.productPageConfigState().reviews.pageSize ?? 5); readonly questionsPageSize = computed(() => this.productPageConfigState().questions.pageSize ?? 5); + readonly reviewsMode = computed(() => this.productPageConfigState().reviews.mode ?? 'pages'); + + readonly actionsConfig = computed(() => { + const config = this.productPageConfigState().actions; + return { + addToCart: config.enabled !== false && config.addToCart !== false, + buyNow: config.enabled !== false && config.buyNow !== false, + wishlist: config.enabled !== false && config.wishlist !== false, + compare: config.enabled !== false && config.compare !== false, + share: config.enabled !== false && config.share !== false, + notifyMe: config.enabled !== false && config.notifyMe !== false, + }; + }); readonly tabsEnabled = computed(() => this.productPageConfigState().tabs.enabled !== false); readonly tabs = computed(() => { @@ -122,12 +143,61 @@ export class ProductDetailsContainerComponent { if (!details?.length) return null; const colour = this.selectedColour(); const size = this.selectedSize(); + const selectedOptions = this.selectedOptions(); return details.find(detail => (!colour || (detail.colour || detail.color) === colour) && - (!size || detail.size === size) + (!size || detail.size === size) && + Object.entries(selectedOptions) + .filter(([key]) => key !== 'colour' && key !== 'size') + .every(([key, value]) => { + const dynamicValue = (detail as any)[key]; + return !value || dynamicValue == null || String(dynamicValue) === value; + }) ) ?? null; }); + readonly variantOptionGroups = computed(() => { + const current = this.product(); + if (!current) { + return []; + } + + if (current.variantOptions?.length) { + return current.variantOptions.map(group => ({ + key: group.key, + label: group.label || group.key, + options: group.options.map(option => ({ + value: option.value, + label: option.label || option.value, + })) + })); + } + + const groups: VariantOptionGroup[] = []; + const colours = this.availableColours(); + const sizes = this.availableSizes(); + + if (colours.length > 0) { + groups.push({ key: 'colour', label: 'itemDetail.colour', options: colours.map(value => ({ value })) }); + } + + if (sizes.length > 0) { + groups.push({ key: 'size', label: 'itemDetail.size', options: sizes.map(value => ({ value })) }); + } + + return groups; + }); + + readonly isInWishlist = computed(() => { + const current = this.product(); + return current ? this.uxFacade.isInWishlist(current.itemID) : false; + }); + + readonly isInCompare = computed(() => { + const current = this.product(); + return current ? this.uxFacade.isInCompare(current.itemID) : false; + }); + readonly effectivePrice = computed(() => this.selectedDetail()?.price ?? this.product()?.price ?? 0); readonly effectiveCurrency = computed(() => this.selectedDetail()?.currency ?? this.product()?.currency ?? ''); readonly effectiveRemaining = computed(() => this.selectedDetail()?.remaining ?? this.product()?.quantity ?? null); @@ -167,7 +237,9 @@ export class ProductDetailsContainerComponent { this.missing.set(false); this.product.set(null); this.relatedProducts.set([]); + this.relatedCollections.set([]); this.selectedPhotoIndex.set(0); + this.selectedOptions.set({}); this.ratingSummary.set(null); this.reviewsResult.set(null); this.questionsResult.set(null); @@ -200,6 +272,7 @@ export class ProductDetailsContainerComponent { selectColour(colour: string): void { this.selectedColour.set(colour); + this.selectedOptions.update(state => ({ ...state, colour })); const sizes = this.availableSizes(); if (sizes.length && this.selectedSize() && !sizes.includes(this.selectedSize()!)) { this.selectedSize.set(sizes[0]); @@ -208,6 +281,20 @@ export class ProductDetailsContainerComponent { selectSize(size: string): void { this.selectedSize.set(size); + this.selectedOptions.update(state => ({ ...state, size })); + } + + selectVariantOption(payload: { groupKey: string; value: string }): void { + this.selectedOptions.update(state => ({ ...state, [payload.groupKey]: payload.value })); + + if (payload.groupKey === 'colour') { + this.selectColour(payload.value); + return; + } + + if (payload.groupKey === 'size') { + this.selectSize(payload.value); + } } addToCart(): void { @@ -221,6 +308,49 @@ export class ProductDetailsContainerComponent { }); } + buyNow(): void { + this.addToCart(); + this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]); + } + + toggleWishlist(): void { + const current = this.product(); + if (!current) { + return; + } + + this.uxFacade.toggleWishlist(current); + } + + toggleCompare(): void { + const current = this.product(); + if (!current) { + return; + } + + if (this.uxFacade.isInCompare(current.itemID)) { + this.uxFacade.removeFromCompare(current.itemID); + return; + } + + const maxItems = Math.max(1, this.userExperienceConfig().compare.maxItems); + this.uxFacade.addToCompare(current, maxItems); + } + + async shareProduct(): Promise { + const current = this.product(); + if (!current || typeof window === 'undefined') { + return; + } + + const url = `${window.location.origin}/${this.languageService.currentLanguage()}/product/${current.itemID}`; + await this.shareService.shareProduct(current, url); + } + + notifyMe(): void { + this.toggleWishlist(); + } + addRelatedToCart(payload: { product: Product; event: Event }): void { payload.event.preventDefault(); payload.event.stopPropagation(); @@ -244,7 +374,7 @@ export class ProductDetailsContainerComponent { this.loadProduct(productId); } - loadReviews(page: number): void { + loadReviews(page: number, append = false): void { const current = this.product(); if (!current || this.productPageConfigState().reviews.enabled === false) { return; @@ -258,7 +388,18 @@ export class ProductDetailsContainerComponent { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: result => { - this.reviewsResult.set(result); + if (append && this.reviewsMode() === 'load-more') { + const previous = this.reviewsResult(); + const mergedItems = [...(previous?.items ?? []), ...result.items] + .filter((item, index, source) => source.findIndex(entry => entry.id === item.id) === index); + + this.reviewsResult.set({ + ...result, + items: mergedItems, + }); + } else { + this.reviewsResult.set(result); + } this.reviewsLoading.set(false); }, error: () => { @@ -305,7 +446,7 @@ export class ProductDetailsContainerComponent { next: () => { this.reviewSubmitting.set(false); this.loadRating(current.itemID); - this.loadReviews(this.reviewsResult()?.page ?? 1); + this.loadReviews(1, false); }, error: () => this.reviewSubmitting.set(false) }); @@ -338,16 +479,55 @@ export class ProductDetailsContainerComponent { if (details?.length) { this.selectedColour.set(details[0].colour || details[0].color || null); this.selectedSize.set(details[0].size && details[0].size.toLowerCase() !== 'default' ? details[0].size : null); + this.selectedOptions.set({ + colour: details[0].colour || details[0].color || '', + size: details[0].size && details[0].size.toLowerCase() !== 'default' ? details[0].size : '' + }); return; } this.selectedColour.set(product.colour ?? null); this.selectedSize.set(product.size && product.size.toLowerCase() !== 'default' ? product.size : null); + this.selectedOptions.set({ + colour: product.colour ?? '', + size: product.size && product.size.toLowerCase() !== 'default' ? product.size : '' + }); } private loadRelatedProducts(product: Product): void { if (this.productPageConfigState().relatedProducts.enabled === false) { this.relatedProducts.set([]); + this.relatedCollections.set([]); + return; + } + + if (product.relatedCollections?.length) { + const language = this.languageService.currentLanguage(); + const requests = product.relatedCollections.map(collection => + forkJoin( + (collection.products ?? []) + .filter(productID => productID !== product.itemID) + .map(productID => this.productFacade.getProduct(productID).pipe(catchError(() => of(null)))) + ).pipe( + catchError(() => of([] as Array)) + ) + ); + + forkJoin(requests) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + const collections = result.map((products, index) => { + const config = product.relatedCollections![index]; + return { + id: config.id, + title: config.titles?.[language] || config.title || this.translate.t('itemDetail.relatedProducts'), + products: products.filter((item): item is Product => !!item) + } as RelatedCollectionViewModel; + }).filter(collection => collection.products.length > 0); + + this.relatedCollections.set(collections); + this.relatedProducts.set(collections[0]?.products ?? []); + }); return; } @@ -357,8 +537,19 @@ export class ProductDetailsContainerComponent { count: 8, }).pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: result => this.relatedProducts.set(result.items.filter(item => item.itemID !== product.itemID)), - error: () => this.relatedProducts.set([]), + next: result => { + const products = result.items.filter(item => item.itemID !== product.itemID); + this.relatedProducts.set(products); + this.relatedCollections.set([{ + id: 'default-related', + title: this.translate.t('itemDetail.relatedProducts'), + products + }]); + }, + error: () => { + this.relatedProducts.set([]); + this.relatedCollections.set([]); + }, }); } @@ -368,7 +559,7 @@ export class ProductDetailsContainerComponent { } if (this.productPageConfigState().reviews.enabled !== false) { - this.loadReviews(1); + this.loadReviews(1, false); } if (this.productPageConfigState().questions.enabled !== false) { @@ -466,6 +657,10 @@ export class ProductDetailsContainerComponent { relatedProducts: { ...DEFAULT_PRODUCT_PAGE_CONFIG.relatedProducts, ...(bootstrapConfig.relatedProducts ?? {}) + }, + actions: { + ...DEFAULT_PRODUCT_PAGE_CONFIG.actions, + ...(bootstrapConfig.actions ?? {}) } }; } diff --git a/src/app/features/website/product/engagement/components/question-list/question-list.component.html b/src/app/features/website/product/engagement/components/question-list/question-list.component.html index f7c0d28..bc6732c 100644 --- a/src/app/features/website/product/engagement/components/question-list/question-list.component.html +++ b/src/app/features/website/product/engagement/components/question-list/question-list.component.html @@ -1,9 +1,13 @@
- + @if (allowSubmission) { + + } @else { +

{{ 'productDetails.questionsSubmissionFuture' | translate }}

+ } - @if (askFormVisible()) { + @if (allowSubmission && askFormVisible()) { } diff --git a/src/app/features/website/product/engagement/components/question-list/question-list.component.scss b/src/app/features/website/product/engagement/components/question-list/question-list.component.scss index a76c3a5..3bd2c5f 100644 --- a/src/app/features/website/product/engagement/components/question-list/question-list.component.scss +++ b/src/app/features/website/product/engagement/components/question-list/question-list.component.scss @@ -31,6 +31,12 @@ color: var(--text-secondary); } +.submission-disabled { + margin: 0; + color: var(--text-secondary); + font-weight: 600; +} + .pager { display: flex; align-items: center; diff --git a/src/app/features/website/product/engagement/components/question-list/question-list.component.ts b/src/app/features/website/product/engagement/components/question-list/question-list.component.ts index e76d646..8ee7e0c 100644 --- a/src/app/features/website/product/engagement/components/question-list/question-list.component.ts +++ b/src/app/features/website/product/engagement/components/question-list/question-list.component.ts @@ -16,6 +16,7 @@ export class QuestionListComponent { @Input() result: EngagementListResult | null = null; @Input() loading = false; @Input() submitting = false; + @Input() allowSubmission = true; @Output() pageChange = new EventEmitter(); @Output() submitQuestion = new EventEmitter(); diff --git a/src/app/features/website/product/engagement/components/review-form/review-form.component.html b/src/app/features/website/product/engagement/components/review-form/review-form.component.html index 1e1d26f..ab9a6a8 100644 --- a/src/app/features/website/product/engagement/components/review-form/review-form.component.html +++ b/src/app/features/website/product/engagement/components/review-form/review-form.component.html @@ -1,17 +1,27 @@

{{ 'productDetails.reviewFormTitle' | translate }}

+ @if (submittedSuccessfully) { +

{{ 'productDetails.reviewSuccess' | translate }}

+ } + - + + @if (showRatingError) { +

{{ 'productDetails.reviewRatingRequired' | translate }}

+ } - + - + + @if (showTextError) { +

{{ 'productDetails.reviewTextRequired' | translate }}

+ } diff --git a/src/app/features/website/product/engagement/components/review-form/review-form.component.scss b/src/app/features/website/product/engagement/components/review-form/review-form.component.scss index d63410f..4809012 100644 --- a/src/app/features/website/product/engagement/components/review-form/review-form.component.scss +++ b/src/app/features/website/product/engagement/components/review-form/review-form.component.scss @@ -41,6 +41,21 @@ textarea { font-size: 0.85rem; } +.success, +.error { + margin: 0; + font-size: 0.85rem; +} + +.success { + color: #166534; + font-weight: 700; +} + +.error { + color: #991b1b; +} + button { justify-self: start; min-height: 40px; diff --git a/src/app/features/website/product/engagement/components/review-form/review-form.component.ts b/src/app/features/website/product/engagement/components/review-form/review-form.component.ts index cc0e795..92351fe 100644 --- a/src/app/features/website/product/engagement/components/review-form/review-form.component.ts +++ b/src/app/features/website/product/engagement/components/review-form/review-form.component.ts @@ -20,8 +20,11 @@ export class ReviewFormComponent { title = ''; text = ''; anonymous = false; + attemptedSubmit = false; + submittedSuccessfully = false; onSubmit(): void { + this.attemptedSubmit = true; const payload: SubmitReviewInput = { rating: this.rating, title: this.title, @@ -34,13 +37,32 @@ export class ReviewFormComponent { } this.submitReview.emit(payload); + this.submittedSuccessfully = true; this.reset(); } + updateRating(value: number): void { + this.rating = value; + this.submittedSuccessfully = false; + } + + hideSuccess(): void { + this.submittedSuccessfully = false; + } + + get showRatingError(): boolean { + return this.attemptedSubmit && !this.rating; + } + + get showTextError(): boolean { + return this.attemptedSubmit && !this.text.trim(); + } + private reset(): void { this.rating = 0; this.title = ''; this.text = ''; this.anonymous = false; + this.attemptedSubmit = false; } } diff --git a/src/app/features/website/product/engagement/components/review-list/review-list.component.html b/src/app/features/website/product/engagement/components/review-list/review-list.component.html index 9cb7673..c3935e8 100644 --- a/src/app/features/website/product/engagement/components/review-list/review-list.component.html +++ b/src/app/features/website/product/engagement/components/review-list/review-list.component.html @@ -20,11 +20,20 @@

{{ 'productDetails.reviewsEmpty' | translate }}

} - @if (result && totalPages > 1) { + @if (result && totalPages > 1 && mode === 'pages') {
{{ 'productDetails.pageOf' | translate:{ page: result.page, total: totalPages } }}
} + + @if (result && mode === 'load-more' && result.items.length < result.total) { +
+ + {{ 'productDetails.showingOf' | translate:{ shown: result.items.length, total: result.total } }} +
+ }
diff --git a/src/app/features/website/product/engagement/components/review-list/review-list.component.scss b/src/app/features/website/product/engagement/components/review-list/review-list.component.scss index c7f19bd..fa36808 100644 --- a/src/app/features/website/product/engagement/components/review-list/review-list.component.scss +++ b/src/app/features/website/product/engagement/components/review-list/review-list.component.scss @@ -35,6 +35,27 @@ transform: translateY(-1px); } +.load-more-wrap { + display: grid; + justify-items: center; + gap: 8px; +} + +.load-more { + min-height: 38px; + border: 1px solid var(--primary-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--primary-color); + padding: 0 16px; + font-weight: 700; + cursor: pointer; +} + +.load-more-wrap small { + color: var(--text-secondary); +} + .skeleton-list { display: grid; gap: 12px; diff --git a/src/app/features/website/product/engagement/components/review-list/review-list.component.ts b/src/app/features/website/product/engagement/components/review-list/review-list.component.ts index e8ef62e..732fa64 100644 --- a/src/app/features/website/product/engagement/components/review-list/review-list.component.ts +++ b/src/app/features/website/product/engagement/components/review-list/review-list.component.ts @@ -20,8 +20,10 @@ export class ReviewListComponent { @Input() loading = false; @Input() submitting = false; @Input() showSummary = true; + @Input() mode: 'pages' | 'load-more' = 'pages'; @Output() pageChange = new EventEmitter(); + @Output() loadMore = new EventEmitter(); @Output() submitReview = new EventEmitter(); get totalPages(): number { @@ -45,4 +47,12 @@ export class ReviewListComponent { this.pageChange.emit(this.result.page + 1); } + + onLoadMore(): void { + if (!this.result || this.result.items.length >= this.result.total || this.loading) { + return; + } + + this.loadMore.emit((this.result.page ?? 1) + 1); + } } diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index e9a42cd..3453134 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -267,8 +267,27 @@ export const en: Translations = { reviewTextPlaceholder: 'Describe your experience', reviewAnonymous: 'Submit anonymously', reviewUploadPlaceholder: 'Photo upload is planned for a future sprint.', + reviewRatingRequired: 'Please select a rating.', + reviewTextRequired: 'Please add your review text.', + reviewSuccess: 'Review submitted successfully.', submitReview: 'Submit review', submitting: 'Submitting...', + loadMore: 'Load more', + showingOf: 'Showing {{shown}} of {{total}}', + questionsSubmissionFuture: 'Question submission can be enabled later without component changes.', + specificationsGeneral: 'General', + actionsAria: 'Product actions', + buyNow: 'Buy now', + wishlist: 'Wishlist', + compare: 'Compare', + share: 'Share', + notifyMe: 'Notify me', + zoom: 'Zoom', + fullscreen: 'Fullscreen', + pdfDocument: 'Product PDF document', + manualDocument: 'Product manual', + warrantyDocument: 'Warranty document', + openDocument: 'Open document', specificationsEmpty: 'No specifications provided yet.', warrantyTitle: 'Warranty and returns', warrantyItem1: 'Warranty terms are provided by the seller and local law.', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 2a578f2..7c8a8a9 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -267,8 +267,27 @@ export const hy: Translations = { reviewTextPlaceholder: 'Նկարագրեք ձեր փորձը', reviewAnonymous: 'Ուղարկել անանուն', reviewUploadPlaceholder: 'Լուսանկար վերբեռնելը նախատեսված է հաջորդ սպրինտում։', + reviewRatingRequired: 'Խնդրում ենք ընտրել գնահատական։', + reviewTextRequired: 'Խնդրում ենք գրել կարծիք։', + reviewSuccess: 'Կարծիքը հաջողությամբ ուղարկվեց։', submitReview: 'Ուղարկել կարծիքը', submitting: 'Ուղարկվում է...', + loadMore: 'Բեռնել ավելին', + showingOf: 'Ցուցադրվում է {{shown}} / {{total}}', + questionsSubmissionFuture: 'Հարցի ուղարկումը կարելի է միացնել ավելի ուշ՝ առանց բաղադրիչի փոփոխության։', + specificationsGeneral: 'Ընդհանուր', + actionsAria: 'Ապրանքի գործողություններ', + buyNow: 'Գնել հիմա', + wishlist: 'Նախընտրածներ', + compare: 'Համեմատել', + share: 'Կիսվել', + notifyMe: 'Ծանուցել ինձ', + zoom: 'Մեծացնել', + fullscreen: 'Ամբողջ էկրան', + pdfDocument: 'Ապրանքի PDF փաստաթուղթ', + manualDocument: 'Ապրանքի ուղեցույց', + warrantyDocument: 'Երաշխիքի փաստաթուղթ', + openDocument: 'Բացել փաստաթուղթը', specificationsEmpty: 'Բնութագրերը դեռ հասանելի չեն։', warrantyTitle: 'Երաշխիք և վերադարձ', warrantyItem1: 'Երաշխիքի պայմանները սահմանվում են վաճառողի և տեղական օրենքներով։', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 3538507..16b8f7c 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -267,8 +267,27 @@ export const ru: Translations = { reviewTextPlaceholder: 'Опишите ваш опыт использования', reviewAnonymous: 'Отправить анонимно', reviewUploadPlaceholder: 'Загрузка фото запланирована в следующем спринте.', + reviewRatingRequired: 'Выберите оценку.', + reviewTextRequired: 'Добавьте текст отзыва.', + reviewSuccess: 'Отзыв успешно отправлен.', submitReview: 'Отправить отзыв', submitting: 'Отправка...', + loadMore: 'Показать еще', + showingOf: 'Показано {{shown}} из {{total}}', + questionsSubmissionFuture: 'Отправку вопросов можно включить позже без изменения компонента.', + specificationsGeneral: 'Общие', + actionsAria: 'Действия с товаром', + buyNow: 'Купить сейчас', + wishlist: 'В избранное', + compare: 'Сравнить', + share: 'Поделиться', + notifyMe: 'Сообщить о наличии', + zoom: 'Увеличить', + fullscreen: 'Полный экран', + pdfDocument: 'PDF документ товара', + manualDocument: 'Инструкция товара', + warrantyDocument: 'Гарантийный документ', + openDocument: 'Открыть документ', specificationsEmpty: 'Характеристики пока не указаны.', warrantyTitle: 'Гарантия и возврат', warrantyItem1: 'Условия гарантии определяются продавцом и местным законодательством.', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 1d63de4..dff27bf 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -265,8 +265,27 @@ export interface Translations { reviewTextPlaceholder: string; reviewAnonymous: string; reviewUploadPlaceholder: string; + reviewRatingRequired: string; + reviewTextRequired: string; + reviewSuccess: string; submitReview: string; submitting: string; + loadMore: string; + showingOf: string; + questionsSubmissionFuture: string; + specificationsGeneral: string; + actionsAria: string; + buyNow: string; + wishlist: string; + compare: string; + share: string; + notifyMe: string; + zoom: string; + fullscreen: string; + pdfDocument: string; + manualDocument: string; + warrantyDocument: string; + openDocument: string; specificationsEmpty: string; warrantyTitle: string; warrantyItem1: string; diff --git a/src/app/models/item.model.ts b/src/app/models/item.model.ts index 9365fc0..38b308e 100644 --- a/src/app/models/item.model.ts +++ b/src/app/models/item.model.ts @@ -5,6 +5,18 @@ interface Photo { type?: string; } +export type ProductMediaType = 'image' | 'video' | 'pdf' | 'manual' | 'warranty'; + +export interface ProductMedia { + id?: string; + type: ProductMediaType; + url: string; + thumbnailUrl?: string; + alt?: string; + title?: string; + labels?: Record; +} + export interface DescriptionField { key: string; value: string; @@ -59,6 +71,47 @@ interface ItemAttribute { value: string; } +interface LocalizedValue { + [language: string]: string; +} + +export interface ProductSpecificationField { + key: string; + value: string; + label?: string; + labels?: LocalizedValue; + unit?: string; +} + +export interface ProductSpecificationGroup { + id?: string; + key: string; + label?: string; + labels?: LocalizedValue; + attributes: ProductSpecificationField[]; +} + +export interface ProductVariantOption { + value: string; + label?: string; + labels?: LocalizedValue; + available?: boolean; +} + +export interface ProductVariantOptionGroup { + key: string; + label?: string; + labels?: LocalizedValue; + options: ProductVariantOption[]; +} + +export interface RelatedProductCollection { + id: string; + title: string; + titles?: LocalizedValue; + products: number[]; +} + export interface DeliveryOption { deliveryPrice: number; deliveryPlace: string; @@ -104,6 +157,10 @@ export interface Item { names?: ItemName[]; descriptions?: ItemDescription[]; attributes?: ItemAttribute[]; + media?: ProductMedia[]; + specificationGroups?: ProductSpecificationGroup[]; + variantOptions?: ProductVariantOptionGroup[]; + relatedCollections?: RelatedProductCollection[]; // BackOffice API fields id?: string; diff --git a/src/app/services/api.service.ts b/src/app/services/api.service.ts index 9e76e5c..95a3517 100644 --- a/src/app/services/api.service.ts +++ b/src/app/services/api.service.ts @@ -98,6 +98,15 @@ export class ApiService { return c.startsWith('0x') ? '#' + c.slice(2) : c; } + private normalizeMediaType(type: unknown): 'image' | 'video' | 'pdf' | 'manual' | 'warranty' { + const value = String(type ?? '').toLowerCase(); + if (value === 'video') return 'video'; + if (value === 'pdf') return 'pdf'; + if (value === 'manual') return 'manual'; + if (value === 'warranty') return 'warranty'; + return 'image'; + } + private normalizeDeliveryData( raw: any, legacyDeliveryPrice?: number @@ -256,6 +265,29 @@ export class ApiService { item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u)) || item.photos?.map((p: any) => p.url) || []; + const rawMedia = Array.isArray(raw.media) ? raw.media : []; + const mediaFromPhotos = (item.photos ?? []).map((photo: any, index: number) => ({ + id: photo.id ?? `photo-${item.itemID}-${index}`, + type: this.normalizeMediaType(photo.type ?? (photo.video ? 'video' : 'image')), + url: this.resolveImageUrl(photo.url), + thumbnailUrl: photo.thumbnailUrl ? this.resolveImageUrl(photo.thumbnailUrl) : undefined, + alt: photo.alt, + title: photo.title, + labels: photo.labels + })); + + item.media = (rawMedia.length > 0 ? rawMedia : mediaFromPhotos) + .map((entry: any, index: number) => ({ + id: entry.id ?? `media-${item.itemID}-${index}`, + type: this.normalizeMediaType(entry.type), + url: this.resolveImageUrl(entry.url), + thumbnailUrl: entry.thumbnailUrl ? this.resolveImageUrl(entry.thumbnailUrl) : undefined, + alt: entry.alt, + title: entry.title, + labels: entry.labels, + })) + .filter((entry: any) => !!entry.url); + // Map backOffice description (key-value array) → legacy description string if (Array.isArray(raw.description)) { item.descriptionFields = raw.description; @@ -287,6 +319,51 @@ export class ApiService { // Preserve attributes from backend item.attributes = raw.attributes || []; + item.specificationGroups = Array.isArray(raw.specificationGroups) + ? raw.specificationGroups.map((group: any, groupIndex: number) => ({ + id: group.id ?? `group-${groupIndex}`, + key: group.key ?? `group-${groupIndex}`, + label: group.label, + labels: group.labels, + attributes: Array.isArray(group.attributes) + ? group.attributes.map((attribute: any, attributeIndex: number) => ({ + key: attribute.key ?? `attribute-${attributeIndex}`, + value: String(attribute.value ?? ''), + label: attribute.label, + labels: attribute.labels, + unit: attribute.unit, + })) + : [] + })) + : []; + + item.variantOptions = Array.isArray(raw.variantOptions) + ? raw.variantOptions.map((group: any, groupIndex: number) => ({ + key: group.key ?? `variant-${groupIndex}`, + label: group.label, + labels: group.labels, + options: Array.isArray(group.options) + ? group.options.map((option: any) => ({ + value: String(option.value ?? ''), + label: option.label, + labels: option.labels, + available: option.available !== false, + })).filter((option: any) => option.value.length > 0) + : [] + })).filter((group: any) => group.options.length > 0) + : []; + + item.relatedCollections = Array.isArray(raw.relatedCollections) + ? raw.relatedCollections.map((collection: any, collectionIndex: number) => ({ + id: String(collection.id ?? `related-${collectionIndex}`), + title: String(collection.title ?? ''), + titles: collection.titles, + products: Array.isArray(collection.products) + ? collection.products.map((productId: any) => Number(productId)).filter((productId: number) => Number.isFinite(productId)) + : [] + })) + : []; + // Preserve colour & size (only if not already set from itemDetails) if (!item.colour) item.colour = this.normalizeColor(raw.colour || ''); if (!item.size) item.size = raw.size || ''; diff --git a/src/app/shared/models/config/product-page-config.model.ts b/src/app/shared/models/config/product-page-config.model.ts index 8410e17..efeeb8c 100644 --- a/src/app/shared/models/config/product-page-config.model.ts +++ b/src/app/shared/models/config/product-page-config.model.ts @@ -7,10 +7,12 @@ export interface ProductRatingConfig extends ProductSectionConfig {} export interface ProductReviewsConfig extends ProductSectionConfig { pageSize?: number; showSummary?: boolean; + mode?: 'pages' | 'load-more'; } export interface ProductQuestionsConfig extends ProductSectionConfig { pageSize?: number; + allowSubmission?: boolean; } export interface ProductTabsConfig extends ProductSectionConfig { @@ -19,12 +21,22 @@ export interface ProductTabsConfig extends ProductSectionConfig { export interface ProductRelatedConfig extends ProductSectionConfig {} +export interface ProductActionsConfig extends ProductSectionConfig { + addToCart?: boolean; + buyNow?: boolean; + wishlist?: boolean; + compare?: boolean; + share?: boolean; + notifyMe?: boolean; +} + export interface ProductPageConfig { rating?: ProductRatingConfig; reviews?: ProductReviewsConfig; questions?: ProductQuestionsConfig; tabs?: ProductTabsConfig; relatedProducts?: ProductRelatedConfig; + actions?: ProductActionsConfig; } export const DEFAULT_PRODUCT_PAGE_CONFIG: Required = { @@ -34,11 +46,13 @@ export const DEFAULT_PRODUCT_PAGE_CONFIG: Required = { reviews: { enabled: true, pageSize: 5, - showSummary: true + showSummary: true, + mode: 'pages' }, questions: { enabled: true, - pageSize: 5 + pageSize: 5, + allowSubmission: true }, tabs: { enabled: true, @@ -46,5 +60,14 @@ export const DEFAULT_PRODUCT_PAGE_CONFIG: Required = { }, relatedProducts: { enabled: true + }, + actions: { + enabled: true, + addToCart: true, + buyNow: true, + wishlist: true, + compare: true, + share: true, + notifyMe: true } };