feat(product): implement sprint 9 product engagement module
This commit is contained in:
112
Sprint-9-Product-Engagement-Report.md
Normal file
112
Sprint-9-Product-Engagement-Report.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Sprint 9 - Product Engagement Report
|
||||||
|
|
||||||
|
## 1. Architecture Decisions
|
||||||
|
- Product engagement was implemented as a dedicated module under product feature: `src/app/features/website/product/engagement/components`.
|
||||||
|
- ProductFacade remained the single feature entry point for engagement flows:
|
||||||
|
- `loadRating(productID)`
|
||||||
|
- `loadReviews(productID, query)`
|
||||||
|
- `loadQuestions(productID, query)`
|
||||||
|
- `submitReview(productID, input)`
|
||||||
|
- `submitQuestion(productID, input)`
|
||||||
|
- No feature component uses `HttpClient` directly.
|
||||||
|
- Data flow remains layered:
|
||||||
|
- Feature Container/Components -> `ProductFacade` -> `ProductDataService` -> `ProductDataProvider` (`ApiProductDataProvider`) -> `ApiService`.
|
||||||
|
- Existing auth/payment/bootstrap initialization flows were not changed.
|
||||||
|
- Widget/section/bootstrap runtime architecture was preserved.
|
||||||
|
|
||||||
|
## 2. Reusable Components Added
|
||||||
|
### Product Engagement Atoms
|
||||||
|
- `StarsComponent`
|
||||||
|
- `StarSelectorComponent`
|
||||||
|
- `ProductTabsComponent`
|
||||||
|
- `RatingSummaryComponent`
|
||||||
|
|
||||||
|
### Reviews
|
||||||
|
- `ReviewFormComponent`
|
||||||
|
- `ReviewCardComponent`
|
||||||
|
- `ReviewListComponent` (includes pagination support)
|
||||||
|
|
||||||
|
### Questions
|
||||||
|
- `QuestionFormComponent`
|
||||||
|
- `QuestionCardComponent`
|
||||||
|
- `QuestionListComponent` (includes pagination + Ask Question toggle)
|
||||||
|
|
||||||
|
### Product Page Sections
|
||||||
|
- Existing reusable sections kept:
|
||||||
|
- Gallery, Product Info, Variant Selector, Delivery, Description, Related Products
|
||||||
|
- New/updated reusable sections:
|
||||||
|
- `ProductSpecificationsComponent`
|
||||||
|
- `ProductWarrantyComponent`
|
||||||
|
- Reviews section via `ReviewListComponent`
|
||||||
|
- Questions section via `QuestionListComponent`
|
||||||
|
|
||||||
|
## 3. Domain Models Added
|
||||||
|
File: `src/app/core/products/models/product-engagement.model.ts`
|
||||||
|
- `RatingSummary`
|
||||||
|
- `Review`
|
||||||
|
- `Question`
|
||||||
|
- `Answer`
|
||||||
|
- Supporting contracts:
|
||||||
|
- `RatingDistributionEntry`
|
||||||
|
- `EngagementListQuery`
|
||||||
|
- `EngagementListResult<T>`
|
||||||
|
- `SubmitReviewInput`
|
||||||
|
- `SubmitQuestionInput`
|
||||||
|
|
||||||
|
## 4. Bootstrap Additions (Feature Config Only)
|
||||||
|
Added new optional config model:
|
||||||
|
- `src/app/shared/models/config/product-page-config.model.ts`
|
||||||
|
|
||||||
|
Added to bootstrap contract:
|
||||||
|
- `BootstrapConfig.productPage?: ProductPageConfig`
|
||||||
|
|
||||||
|
Added to mock bootstrap example:
|
||||||
|
- `src/assets/mock/bootstrap/bootstrap.json` now contains `productPage` with:
|
||||||
|
- `rating.enabled`
|
||||||
|
- `reviews.enabled/pageSize/showSummary`
|
||||||
|
- `questions.enabled/pageSize`
|
||||||
|
- `tabs.enabled/items`
|
||||||
|
- `relatedProducts.enabled`
|
||||||
|
|
||||||
|
No review/question domain data was placed in bootstrap.
|
||||||
|
|
||||||
|
## 5. Backend Endpoints Expected
|
||||||
|
Prepared in docs/contracts as target backend API:
|
||||||
|
- `GET /products/{id}/rating`
|
||||||
|
- `GET /products/{id}/reviews`
|
||||||
|
- `GET /products/{id}/questions`
|
||||||
|
- `POST /products/{id}/reviews`
|
||||||
|
- `POST /products/{id}/questions`
|
||||||
|
|
||||||
|
Current runtime uses existing available mock/API item data mapping where applicable, while facade methods and contracts are already prepared for dedicated engagement endpoints.
|
||||||
|
|
||||||
|
## 6. Validation Results
|
||||||
|
### Build
|
||||||
|
- `npm run build` -> PASS
|
||||||
|
|
||||||
|
### Architecture Rules
|
||||||
|
- No `HttpClient` usage found in feature layer (`src/app/features/**`).
|
||||||
|
- No `environment.*` usage found in feature layer (`src/app/features/**`).
|
||||||
|
- No DTO imports found in feature layer (`src/app/features/**`, checked for `\bdto\b|\.dto`).
|
||||||
|
|
||||||
|
### Architecture Governance Scripts
|
||||||
|
- `npm run arch:check:boundaries` -> PASS
|
||||||
|
- `npm run arch:check:cycles` -> PASS (`No circular dependency found`)
|
||||||
|
|
||||||
|
## 7. Documentation Updated
|
||||||
|
Platform docs:
|
||||||
|
- `docs/platform/00-bootstrap-example.md`
|
||||||
|
- `docs/platform/02-bootstrap-json-spec.md`
|
||||||
|
- `docs/platform/06-api-contracts.md`
|
||||||
|
- `docs/platform/10-product-domain.md`
|
||||||
|
- `docs/platform/13-backend-requirements.md`
|
||||||
|
|
||||||
|
Backend docs:
|
||||||
|
- `docs/backend-platform/business-apis.md`
|
||||||
|
|
||||||
|
## 8. Future Extension Points
|
||||||
|
- Photo upload support in review form (placeholder is already present in UI).
|
||||||
|
- Backend-side moderation and verification flags for reviews/questions.
|
||||||
|
- Dedicated seller identity and accepted-answer workflow integration.
|
||||||
|
- Server-driven sorting/filtering for reviews and questions.
|
||||||
|
- Optional widgetized engagement blocks for dynamic page composition.
|
||||||
@@ -49,6 +49,54 @@ Tenant rule:
|
|||||||
Must not change:
|
Must not change:
|
||||||
- item identifiers/price semantics relied on frontend checkout/cart logic.
|
- item identifiers/price semantics relied on frontend checkout/cart logic.
|
||||||
|
|
||||||
|
## /products/{id}/rating
|
||||||
|
Purpose:
|
||||||
|
- return product rating aggregate for engagement UI.
|
||||||
|
|
||||||
|
High-level response shape:
|
||||||
|
- average rating
|
||||||
|
- total reviews
|
||||||
|
- star distribution (5..1)
|
||||||
|
|
||||||
|
Tenant rule:
|
||||||
|
- aggregate must be computed only from tenant-visible reviews.
|
||||||
|
|
||||||
|
## /products/{id}/reviews
|
||||||
|
Purpose:
|
||||||
|
- paginated review feed for product page.
|
||||||
|
|
||||||
|
High-level response shape:
|
||||||
|
- review list
|
||||||
|
- page/pageSize/total metadata
|
||||||
|
|
||||||
|
Tenant rule:
|
||||||
|
- only tenant-allowed and moderation-approved reviews.
|
||||||
|
|
||||||
|
## /products/{id}/questions
|
||||||
|
Purpose:
|
||||||
|
- paginated questions and answers feed for product page.
|
||||||
|
|
||||||
|
High-level response shape:
|
||||||
|
- question list with answers
|
||||||
|
- page/pageSize/total metadata
|
||||||
|
|
||||||
|
Tenant rule:
|
||||||
|
- only tenant-visible questions/answers.
|
||||||
|
|
||||||
|
## /products/{id}/reviews (POST)
|
||||||
|
Purpose:
|
||||||
|
- create review (rating/title/text/anonymous).
|
||||||
|
|
||||||
|
Must not change:
|
||||||
|
- request validation semantics expected by frontend engagement form.
|
||||||
|
|
||||||
|
## /products/{id}/questions (POST)
|
||||||
|
Purpose:
|
||||||
|
- create product question (text/anonymous).
|
||||||
|
|
||||||
|
Must not change:
|
||||||
|
- acknowledgement contract expected by frontend engagement form.
|
||||||
|
|
||||||
## /categories
|
## /categories
|
||||||
Purpose:
|
Purpose:
|
||||||
- category tree retrieval
|
- category tree retrieval
|
||||||
|
|||||||
@@ -296,6 +296,23 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"productPage": {
|
||||||
|
"rating": { "enabled": true },
|
||||||
|
"reviews": {
|
||||||
|
"enabled": true,
|
||||||
|
"pageSize": 5,
|
||||||
|
"showSummary": true
|
||||||
|
},
|
||||||
|
"questions": {
|
||||||
|
"enabled": true,
|
||||||
|
"pageSize": 5
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"enabled": true,
|
||||||
|
"items": ["description", "specifications", "reviews", "questions", "delivery", "warranty"]
|
||||||
|
},
|
||||||
|
"relatedProducts": { "enabled": true }
|
||||||
|
},
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"id": "page-home",
|
"id": "page-home",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Bootstrap JSON является главным конфигурационным
|
|||||||
- branding
|
- branding
|
||||||
- navigation
|
- navigation
|
||||||
- footer
|
- footer
|
||||||
|
- productPage
|
||||||
- staticPages
|
- staticPages
|
||||||
- widgetRegistry
|
- widgetRegistry
|
||||||
- visibility
|
- visibility
|
||||||
@@ -69,6 +70,18 @@ Bootstrap JSON является главным конфигурационным
|
|||||||
- Для виджетов допустимы metadata поля: order, padding, visibility.desktop/tablet/mobile.
|
- Для виджетов допустимы metadata поля: order, padding, visibility.desktop/tablet/mobile.
|
||||||
- Для footer links/legal/payout icons источник истины — bootstrap JSON.
|
- Для footer links/legal/payout icons источник истины — bootstrap JSON.
|
||||||
- Для staticPages контент поддерживается в формате multilingual HTML и рендерится только через safe sanitizer.
|
- Для staticPages контент поддерживается в формате multilingual HTML и рендерится только через safe sanitizer.
|
||||||
|
- Для productPage допускаются только feature-конфиги (enabled/pageSize/tabs/showSummary), без доменных данных отзывов и вопросов.
|
||||||
|
|
||||||
|
### Product Engagement Config (опционально)
|
||||||
|
- productPage.rating.enabled: boolean
|
||||||
|
- productPage.reviews.enabled: boolean
|
||||||
|
- productPage.reviews.pageSize: number
|
||||||
|
- productPage.reviews.showSummary: boolean
|
||||||
|
- productPage.questions.enabled: boolean
|
||||||
|
- productPage.questions.pageSize: number
|
||||||
|
- productPage.tabs.enabled: boolean
|
||||||
|
- productPage.tabs.items: array (description/specifications/reviews/questions/delivery/warranty)
|
||||||
|
- productPage.relatedProducts.enabled: boolean
|
||||||
|
|
||||||
## Пример полного минимального bootstrap
|
## Пример полного минимального bootstrap
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -24,6 +24,21 @@
|
|||||||
- В ответах на списки должна поддерживаться пагинация.
|
- В ответах на списки должна поддерживаться пагинация.
|
||||||
- Ошибки API должны быть машиночитаемыми и локализуемыми на frontend.
|
- Ошибки API должны быть машиночитаемыми и локализуемыми на frontend.
|
||||||
|
|
||||||
|
## Product Engagement API (ожидаемый контракт)
|
||||||
|
- GET /products/{id}/rating
|
||||||
|
- возвращает агрегированную оценку и распределение по звездам.
|
||||||
|
- GET /products/{id}/reviews
|
||||||
|
- поддерживает пагинацию (page/pageSize).
|
||||||
|
- GET /products/{id}/questions
|
||||||
|
- поддерживает пагинацию (page/pageSize).
|
||||||
|
- POST /products/{id}/reviews
|
||||||
|
- принимает rating/title/text/anonymous.
|
||||||
|
- POST /products/{id}/questions
|
||||||
|
- принимает text/anonymous.
|
||||||
|
|
||||||
|
Правило:
|
||||||
|
- Feature UI не вызывает API напрямую; запросы идут через ProductFacade -> domain service -> provider/repository.
|
||||||
|
|
||||||
## Пример API ответа: категории
|
## Пример API ответа: категории
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,11 +25,30 @@
|
|||||||
- stockStatus
|
- stockStatus
|
||||||
- rating
|
- rating
|
||||||
|
|
||||||
|
## Product Engagement Models
|
||||||
|
- RatingSummary
|
||||||
|
- average: number
|
||||||
|
- totalReviews: number
|
||||||
|
- distribution: [{ stars, count, share }]
|
||||||
|
- Review
|
||||||
|
- id, rating, title, text, author, anonymous, verifiedPurchase, createdAt
|
||||||
|
- likes/dislikes placeholders
|
||||||
|
- photos: string[] (reserved for future uploads)
|
||||||
|
- Question
|
||||||
|
- id, text, author, createdAt, likes, dislikes
|
||||||
|
- answers: Answer[]
|
||||||
|
- Answer
|
||||||
|
- id, text, author, createdAt
|
||||||
|
- isOfficialSeller, isAccepted
|
||||||
|
|
||||||
## Строгие правила
|
## Строгие правила
|
||||||
- Цена и валюта должны передаваться как валидная пара.
|
- Цена и валюта должны передаваться как валидная пара.
|
||||||
- Скрытые товары не участвуют в публичных витринах.
|
- Скрытые товары не участвуют в публичных витринах.
|
||||||
- categoryID должен ссылаться на существующую категорию.
|
- categoryID должен ссылаться на существующую категорию.
|
||||||
- Виджет не изменяет товарные данные, только отображает.
|
- Виджет не изменяет товарные данные, только отображает.
|
||||||
|
- Frontend feature-слой работает только через ProductFacade.
|
||||||
|
- DTO/API shape не импортируется в feature components.
|
||||||
|
- reviews/questions не хранятся в bootstrap, только их feature-конфиг.
|
||||||
|
|
||||||
## Пример JSON товара
|
## Пример JSON товара
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
- Выдача bootstrap.json для tenant.
|
- Выдача bootstrap.json для tenant.
|
||||||
- API категорий, товаров, карточек, поисковых выборок.
|
- API категорий, товаров, карточек, поисковых выборок.
|
||||||
- Выдача навигации, статических страниц и feature flags.
|
- Выдача навигации, статических страниц и feature flags.
|
||||||
|
- Product Engagement API для рейтинга, отзывов и вопросов.
|
||||||
|
|
||||||
### Контракт статических страниц
|
### Контракт статических страниц
|
||||||
Backend должен поддерживать формат:
|
Backend должен поддерживать формат:
|
||||||
@@ -26,8 +27,19 @@ Backend должен поддерживать формат:
|
|||||||
- Bootstrap контракт со schemaVersion.
|
- Bootstrap контракт со schemaVersion.
|
||||||
- Категории: id/title/parentId/visible/priority.
|
- Категории: id/title/parentId/visible/priority.
|
||||||
- Товары: itemID/name/price/currency/categoryID/visible.
|
- Товары: itemID/name/price/currency/categoryID/visible.
|
||||||
|
- Product Engagement:
|
||||||
|
- RatingSummary (average, totalReviews, distribution)
|
||||||
|
- Review (id, rating, text, createdAt, author)
|
||||||
|
- Question (id, text, createdAt, answers)
|
||||||
- Унифицированный формат ошибок API.
|
- Унифицированный формат ошибок API.
|
||||||
|
|
||||||
|
## Обязательные Product Engagement endpoints
|
||||||
|
- GET /products/{id}/rating
|
||||||
|
- GET /products/{id}/reviews?page={n}&pageSize={n}
|
||||||
|
- GET /products/{id}/questions?page={n}&pageSize={n}
|
||||||
|
- POST /products/{id}/reviews
|
||||||
|
- POST /products/{id}/questions
|
||||||
|
|
||||||
## Опциональные JSON-контракты
|
## Опциональные JSON-контракты
|
||||||
- Персонализированные рекомендации.
|
- Персонализированные рекомендации.
|
||||||
- Расширенные facets/filters.
|
- Расширенные facets/filters.
|
||||||
|
|||||||
72
src/app/core/products/models/product-engagement.model.ts
Normal file
72
src/app/core/products/models/product-engagement.model.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export type RatingStars = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
|
export interface RatingDistributionEntry {
|
||||||
|
stars: RatingStars;
|
||||||
|
count: number;
|
||||||
|
share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatingSummary {
|
||||||
|
average: number;
|
||||||
|
totalReviews: number;
|
||||||
|
distribution: RatingDistributionEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Review {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
author: string;
|
||||||
|
anonymous: boolean;
|
||||||
|
verifiedPurchase: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
likes: number;
|
||||||
|
dislikes: number;
|
||||||
|
photos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Answer {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
author: string;
|
||||||
|
createdAt: string;
|
||||||
|
isOfficialSeller: boolean;
|
||||||
|
isAccepted: boolean;
|
||||||
|
likes: number;
|
||||||
|
dislikes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
author: string;
|
||||||
|
createdAt: string;
|
||||||
|
likes: number;
|
||||||
|
dislikes: number;
|
||||||
|
answers: Answer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngagementListQuery {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngagementListResult<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitReviewInput {
|
||||||
|
rating: number;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
anonymous: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitQuestionInput {
|
||||||
|
text: string;
|
||||||
|
anonymous: boolean;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { PRODUCT_DATA_PROVIDER } from './product-data-provider.token';
|
import { PRODUCT_DATA_PROVIDER } from './product-data-provider.token';
|
||||||
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from './models/product-domain.model';
|
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from './models/product-domain.model';
|
||||||
|
import { EngagementListQuery, EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from './models/product-engagement.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ProductDataService {
|
export class ProductDataService {
|
||||||
@@ -38,4 +39,24 @@ export class ProductDataService {
|
|||||||
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult> {
|
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult> {
|
||||||
return this.provider.getRelatedProducts(query);
|
return this.provider.getRelatedProducts(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadRating(productID: number): Observable<RatingSummary> {
|
||||||
|
return this.provider.loadRating(productID);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadReviews(productID: number, query?: EngagementListQuery): Observable<EngagementListResult<Review>> {
|
||||||
|
return this.provider.loadReviews(productID, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadQuestions(productID: number, query?: EngagementListQuery): Observable<EngagementListResult<Question>> {
|
||||||
|
return this.provider.loadQuestions(productID, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitReview(productID: number, input: SubmitReviewInput): Observable<void> {
|
||||||
|
return this.provider.submitReview(productID, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitQuestion(productID: number, input: SubmitQuestionInput): Observable<void> {
|
||||||
|
return this.provider.submitQuestion(productID, input);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Observable, map } from 'rxjs';
|
import { Observable, map } from 'rxjs';
|
||||||
import { ApiService } from '../../../services';
|
import { ApiService } from '../../../services';
|
||||||
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { CategoryService } from '../../categories/category.service';
|
import { CategoryService } from '../../categories/category.service';
|
||||||
import { ProductDataProvider } from './product-data-provider.interface';
|
import { ProductDataProvider } from './product-data-provider.interface';
|
||||||
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';
|
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';
|
||||||
|
import { Answer, EngagementListQuery, EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../models/product-engagement.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ApiProductDataProvider implements ProductDataProvider {
|
export class ApiProductDataProvider implements ProductDataProvider {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
private readonly categoryService: CategoryService
|
private readonly categoryService: CategoryService,
|
||||||
|
private readonly authService: AuthService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getProducts(query: ProductListQuery = {}): Observable<ProductListResult> {
|
getProducts(query: ProductListQuery = {}): Observable<ProductListResult> {
|
||||||
@@ -58,6 +61,43 @@ export class ApiProductDataProvider implements ProductDataProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadRating(productID: number): Observable<RatingSummary> {
|
||||||
|
return this.apiService.getItem(productID).pipe(
|
||||||
|
map(item => this.toRatingSummary(this.toReviews(item)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadReviews(productID: number, query: EngagementListQuery = {}): Observable<EngagementListResult<Review>> {
|
||||||
|
return this.apiService.getItem(productID).pipe(
|
||||||
|
map(item => this.paginate(this.toReviews(item), query))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadQuestions(productID: number, query: EngagementListQuery = {}): Observable<EngagementListResult<Question>> {
|
||||||
|
return this.apiService.getItem(productID).pipe(
|
||||||
|
map(item => this.paginate(this.toQuestions(item), query))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitReview(productID: number, input: SubmitReviewInput): Observable<void> {
|
||||||
|
return this.apiService.submitReview({
|
||||||
|
itemID: productID,
|
||||||
|
rating: input.rating,
|
||||||
|
comment: input.title.trim() ? `${input.title.trim()}\n\n${input.text.trim()}` : input.text.trim(),
|
||||||
|
sessionID: this.authService.session()?.sessionId || 'anonymous',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}).pipe(map(() => void 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
submitQuestion(productID: number, input: SubmitQuestionInput): Observable<void> {
|
||||||
|
return this.apiService.submitQuestion({
|
||||||
|
itemID: productID,
|
||||||
|
question: input.text.trim(),
|
||||||
|
sessionID: this.authService.session()?.sessionId || 'anonymous',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}).pipe(map(() => void 0));
|
||||||
|
}
|
||||||
|
|
||||||
private toSearchOptions(query: ProductListQuery) {
|
private toSearchOptions(query: ProductListQuery) {
|
||||||
return {
|
return {
|
||||||
categoryIDs: query.categoryIDs,
|
categoryIDs: query.categoryIDs,
|
||||||
@@ -76,4 +116,112 @@ export class ApiProductDataProvider implements ProductDataProvider {
|
|||||||
skip: query.skip ?? 0
|
skip: query.skip ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toReviews(product: Product): Review[] {
|
||||||
|
const comments = product.comments ?? [];
|
||||||
|
const callbacks = product.callbacks ?? [];
|
||||||
|
|
||||||
|
const merged = comments.length > 0
|
||||||
|
? comments.map((comment, index) => ({
|
||||||
|
id: comment.id ?? `comment-${product.itemID}-${index}`,
|
||||||
|
rating: comment.stars ?? 0,
|
||||||
|
content: comment.text ?? '',
|
||||||
|
author: comment.author ?? 'Anonymous',
|
||||||
|
createdAt: comment.createdAt ?? new Date().toISOString()
|
||||||
|
}))
|
||||||
|
: callbacks.map((callback, index) => ({
|
||||||
|
id: `callback-${product.itemID}-${index}`,
|
||||||
|
rating: callback.rating ?? 0,
|
||||||
|
content: callback.content ?? '',
|
||||||
|
author: callback.userID ?? 'Anonymous',
|
||||||
|
createdAt: callback.timestamp ?? new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
return merged
|
||||||
|
.filter(review => review.content.trim().length > 0)
|
||||||
|
.map(review => {
|
||||||
|
const parts = review.content.split('\n\n');
|
||||||
|
const maybeTitle = parts.length > 1 ? parts[0].trim() : '';
|
||||||
|
const body = parts.length > 1 ? parts.slice(1).join('\n\n').trim() : review.content.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: review.id,
|
||||||
|
rating: Math.max(1, Math.min(5, review.rating || 0)),
|
||||||
|
title: maybeTitle,
|
||||||
|
text: body,
|
||||||
|
author: review.author || 'Anonymous',
|
||||||
|
anonymous: review.author === 'Anonymous',
|
||||||
|
verifiedPurchase: false,
|
||||||
|
createdAt: review.createdAt,
|
||||||
|
likes: 0,
|
||||||
|
dislikes: 0,
|
||||||
|
photos: []
|
||||||
|
} as Review;
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
private toQuestions(product: Product): Question[] {
|
||||||
|
return (product.questions ?? []).map((question, index) => {
|
||||||
|
const answer: Answer | null = question.answer
|
||||||
|
? {
|
||||||
|
id: `answer-${product.itemID}-${index}`,
|
||||||
|
text: question.answer,
|
||||||
|
author: 'Seller',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
isOfficialSeller: true,
|
||||||
|
isAccepted: true,
|
||||||
|
likes: 0,
|
||||||
|
dislikes: 0
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `question-${product.itemID}-${index}`,
|
||||||
|
text: question.question,
|
||||||
|
author: 'Customer',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
likes: question.like ?? question.upvotes ?? 0,
|
||||||
|
dislikes: question.dislike ?? question.downvotes ?? 0,
|
||||||
|
answers: answer ? [answer] : []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRatingSummary(reviews: Review[]): RatingSummary {
|
||||||
|
const baseCounts: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
||||||
|
|
||||||
|
for (const review of reviews) {
|
||||||
|
const stars = Math.max(1, Math.min(5, Math.round(review.rating)));
|
||||||
|
baseCounts[stars] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalReviews = reviews.length;
|
||||||
|
const average = totalReviews > 0
|
||||||
|
? Number((reviews.reduce((acc, review) => acc + review.rating, 0) / totalReviews).toFixed(1))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
average,
|
||||||
|
totalReviews,
|
||||||
|
distribution: [5, 4, 3, 2, 1].map(stars => ({
|
||||||
|
stars: stars as 1 | 2 | 3 | 4 | 5,
|
||||||
|
count: baseCounts[stars],
|
||||||
|
share: totalReviews > 0 ? baseCounts[stars] / totalReviews : 0
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private paginate<T>(items: T[], query: EngagementListQuery): EngagementListResult<T> {
|
||||||
|
const pageSize = Math.max(1, query.pageSize ?? 5);
|
||||||
|
const page = Math.max(1, query.page ?? 1);
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.slice(skip, skip + pageSize),
|
||||||
|
total: items.length,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';
|
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';
|
||||||
|
import { EngagementListQuery, EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../models/product-engagement.model';
|
||||||
|
|
||||||
export interface ProductDataProvider {
|
export interface ProductDataProvider {
|
||||||
getProducts(query?: ProductListQuery): Observable<ProductListResult>;
|
getProducts(query?: ProductListQuery): Observable<ProductListResult>;
|
||||||
@@ -10,4 +11,9 @@ export interface ProductDataProvider {
|
|||||||
getLatestProducts(query?: ProductListQuery): Observable<ProductListResult>;
|
getLatestProducts(query?: ProductListQuery): Observable<ProductListResult>;
|
||||||
getProductsByCategory(categoryID: number, query?: ProductListQuery): Observable<ProductListResult>;
|
getProductsByCategory(categoryID: number, query?: ProductListQuery): Observable<ProductListResult>;
|
||||||
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult>;
|
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult>;
|
||||||
|
loadRating(productID: number): Observable<RatingSummary>;
|
||||||
|
loadReviews(productID: number, query?: EngagementListQuery): Observable<EngagementListResult<Review>>;
|
||||||
|
loadQuestions(productID: number, query?: EngagementListQuery): Observable<EngagementListResult<Question>>;
|
||||||
|
submitReview(productID: number, input: SubmitReviewInput): Observable<void>;
|
||||||
|
submitQuestion(productID: number, input: SubmitQuestionInput): Observable<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ProductSearchQuery,
|
ProductSearchQuery,
|
||||||
RelatedProductsQuery,
|
RelatedProductsQuery,
|
||||||
} from '../../core/products/models/product-domain.model';
|
} from '../../core/products/models/product-domain.model';
|
||||||
|
import { EngagementListQuery, EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../core/products/models/product-engagement.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ProductFacade {
|
export class ProductFacade {
|
||||||
@@ -45,4 +46,24 @@ export class ProductFacade {
|
|||||||
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult> {
|
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult> {
|
||||||
return this.productData.getRelatedProducts(query);
|
return this.productData.getRelatedProducts(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadRating(productID: number): Observable<RatingSummary> {
|
||||||
|
return this.productData.loadRating(productID);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadReviews(productID: number, query?: EngagementListQuery): Observable<EngagementListResult<Review>> {
|
||||||
|
return this.productData.loadReviews(productID, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadQuestions(productID: number, query?: EngagementListQuery): Observable<EngagementListResult<Question>> {
|
||||||
|
return this.productData.loadQuestions(productID, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitReview(productID: number, input: SubmitReviewInput): Observable<void> {
|
||||||
|
return this.productData.submitReview(productID, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitQuestion(productID: number, input: SubmitQuestionInput): Observable<void> {
|
||||||
|
return this.productData.submitQuestion(productID, input);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,6 @@
|
|||||||
|
|
||||||
@if (simpleDescription) {
|
@if (simpleDescription) {
|
||||||
<p>{{ simpleDescription }}</p>
|
<p>{{ simpleDescription }}</p>
|
||||||
}
|
|
||||||
|
|
||||||
@if (descriptionFields.length > 0) {
|
|
||||||
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
|
|
||||||
<dl>
|
|
||||||
@for (field of descriptionFields; track field.key) {
|
|
||||||
<div>
|
|
||||||
<dt>{{ field.key }}</dt>
|
|
||||||
<dd>{{ field.value }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
} @else if (product.description) {
|
} @else if (product.description) {
|
||||||
<p>{{ product.description }}</p>
|
<p>{{ product.description }}</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,55 +2,20 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
h2,
|
border: 1px solid var(--border-color);
|
||||||
h3 {
|
border-radius: var(--radius-md);
|
||||||
margin: 0;
|
background: var(--bg-primary);
|
||||||
color: #1e3c38;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #1e3c38;
|
||||||
font-size: 1.45rem;
|
font-size: 1.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #697777;
|
color: #697777;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
dl {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dl div {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(120px, 0.42fr) 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid #e4e9e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
color: #697777;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
color: #1e3c38;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.product-description dl div {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,4 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
|||||||
export class ProductDescriptionComponent {
|
export class ProductDescriptionComponent {
|
||||||
@Input({ required: true }) product!: Product;
|
@Input({ required: true }) product!: Product;
|
||||||
@Input() simpleDescription = '';
|
@Input() simpleDescription = '';
|
||||||
|
|
||||||
get descriptionFields() {
|
|
||||||
return this.product.descriptionFields ?? [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<section class="product-specifications card section">
|
||||||
|
<h3>Specifications</h3>
|
||||||
|
|
||||||
|
@if (descriptionFields.length > 0) {
|
||||||
|
<dl>
|
||||||
|
@for (field of descriptionFields; track field.key) {
|
||||||
|
<div>
|
||||||
|
<dt>{{ field.key }}</dt>
|
||||||
|
<dd>{{ field.value }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
} @else {
|
||||||
|
<p>No specifications provided yet.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.product-specifications {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
dl div {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
|
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-specifications',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './product-specifications.component.html',
|
||||||
|
styleUrls: ['./product-specifications.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ProductSpecificationsComponent {
|
||||||
|
@Input({ required: true }) product!: Product;
|
||||||
|
|
||||||
|
get descriptionFields() {
|
||||||
|
return this.product.descriptionFields ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<section class="product-warranty card section">
|
||||||
|
<h3>Warranty and returns</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Warranty terms are provided by the seller and local law.</li>
|
||||||
|
<li>Return eligibility depends on item condition and category.</li>
|
||||||
|
<li>Detailed return instructions are available after purchase.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.product-warranty {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-warranty',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './product-warranty.component.html',
|
||||||
|
styleUrls: ['./product-warranty.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ProductWarrantyComponent {}
|
||||||
@@ -58,8 +58,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<app-product-description [product]="currentProduct" [simpleDescription]="productDescription(currentProduct)" />
|
@if (tabsEnabled()) {
|
||||||
|
<section class="product-engagement">
|
||||||
|
<app-product-tabs
|
||||||
|
[tabs]="tabs()"
|
||||||
|
[activeKey]="activeTab()"
|
||||||
|
(activeKeyChange)="activeTab.set($event)" />
|
||||||
|
|
||||||
|
<div class="product-tab-content">
|
||||||
|
@switch (activeTab()) {
|
||||||
|
@case ('description') {
|
||||||
|
<app-product-description [product]="currentProduct" [simpleDescription]="productDescription(currentProduct)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('specifications') {
|
||||||
|
<app-product-specifications [product]="currentProduct" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('reviews') {
|
||||||
|
<app-review-list
|
||||||
|
[result]="reviewsResult()"
|
||||||
|
[ratingSummary]="ratingSummary()"
|
||||||
|
[loading]="reviewsLoading() || ratingLoading()"
|
||||||
|
[submitting]="reviewSubmitting()"
|
||||||
|
[showSummary]="productPageConfigState().reviews.showSummary !== false"
|
||||||
|
(pageChange)="loadReviews($event)"
|
||||||
|
(submitReview)="submitReview($event)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('questions') {
|
||||||
|
<app-question-list
|
||||||
|
[result]="questionsResult()"
|
||||||
|
[loading]="questionsLoading()"
|
||||||
|
[submitting]="questionSubmitting()"
|
||||||
|
(pageChange)="loadQuestions($event)"
|
||||||
|
(submitQuestion)="submitQuestion($event)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('delivery') {
|
||||||
|
<app-product-delivery-information [product]="currentProduct" [currency]="effectiveCurrency()" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('warranty') {
|
||||||
|
<app-product-warranty />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
} @else {
|
||||||
|
<section class="product-engagement product-engagement-stacked">
|
||||||
|
@if (isSectionEnabled('description')) {
|
||||||
|
<app-product-description [product]="currentProduct" [simpleDescription]="productDescription(currentProduct)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isSectionEnabled('specifications')) {
|
||||||
|
<app-product-specifications [product]="currentProduct" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isSectionEnabled('reviews')) {
|
||||||
|
<app-review-list
|
||||||
|
[result]="reviewsResult()"
|
||||||
|
[ratingSummary]="ratingSummary()"
|
||||||
|
[loading]="reviewsLoading() || ratingLoading()"
|
||||||
|
[submitting]="reviewSubmitting()"
|
||||||
|
[showSummary]="productPageConfigState().reviews.showSummary !== false"
|
||||||
|
(pageChange)="loadReviews($event)"
|
||||||
|
(submitReview)="submitReview($event)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isSectionEnabled('questions')) {
|
||||||
|
<app-question-list
|
||||||
|
[result]="questionsResult()"
|
||||||
|
[loading]="questionsLoading()"
|
||||||
|
[submitting]="questionSubmitting()"
|
||||||
|
(pageChange)="loadQuestions($event)"
|
||||||
|
(submitQuestion)="submitQuestion($event)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isSectionEnabled('delivery')) {
|
||||||
|
<app-product-delivery-information [product]="currentProduct" [currency]="effectiveCurrency()" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isSectionEnabled('warranty')) {
|
||||||
|
<app-product-warranty />
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (productPageConfigState().relatedProducts.enabled !== false) {
|
||||||
<app-related-products
|
<app-related-products
|
||||||
[products]="relatedProducts()"
|
[products]="relatedProducts()"
|
||||||
(productSelected)="selectRelatedProduct($event)"
|
(productSelected)="selectRelatedProduct($event)"
|
||||||
@@ -67,4 +153,5 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -20,6 +20,21 @@
|
|||||||
gap: 22px;
|
gap: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-engagement {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-tab-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
animation: section-fade-in 280ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-engagement-stacked {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.product-details-loading,
|
.product-details-loading,
|
||||||
.product-details-message {
|
.product-details-message {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
|
|||||||
@@ -2,18 +2,26 @@ import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signa
|
|||||||
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 { 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 { ConfigService } from '../../../../core/config/config.service';
|
||||||
import { ProductFacade } from '../../../../facades/platform/product.facade';
|
import { ProductFacade } from '../../../../facades/platform/product.facade';
|
||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
|
||||||
import { CartService } from '../../../../services';
|
import { CartService } from '../../../../services';
|
||||||
import { LanguageService } from '../../../../services/language.service';
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
|
import { DEFAULT_PRODUCT_PAGE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
|
||||||
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
||||||
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
|
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.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 { ProductWarrantyComponent } from '../components/product-warranty/product-warranty.component';
|
||||||
import { RelatedProductsComponent } from '../components/related-products/related-products.component';
|
import { RelatedProductsComponent } from '../components/related-products/related-products.component';
|
||||||
import { ProductVariantSelectorComponent } from '../components/variant-selector/variant-selector.component';
|
import { ProductVariantSelectorComponent } 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';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-details-container',
|
selector: 'app-product-details-container',
|
||||||
@@ -27,6 +35,11 @@ import { ProductVariantSelectorComponent } from '../components/variant-selector/
|
|||||||
ProductVariantSelectorComponent,
|
ProductVariantSelectorComponent,
|
||||||
ProductDeliveryInformationComponent,
|
ProductDeliveryInformationComponent,
|
||||||
ProductDescriptionComponent,
|
ProductDescriptionComponent,
|
||||||
|
ProductSpecificationsComponent,
|
||||||
|
ProductWarrantyComponent,
|
||||||
|
ProductTabsComponent,
|
||||||
|
ReviewListComponent,
|
||||||
|
QuestionListComponent,
|
||||||
RelatedProductsComponent,
|
RelatedProductsComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './product-details-container.component.html',
|
templateUrl: './product-details-container.component.html',
|
||||||
@@ -37,10 +50,13 @@ export class ProductDetailsContainerComponent {
|
|||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly configService = inject(ConfigService);
|
||||||
private readonly productFacade = inject(ProductFacade);
|
private readonly productFacade = inject(ProductFacade);
|
||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
|
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
|
||||||
|
|
||||||
readonly product = signal<Product | null>(null);
|
readonly product = signal<Product | null>(null);
|
||||||
readonly relatedProducts = signal<Product[]>([]);
|
readonly relatedProducts = signal<Product[]>([]);
|
||||||
readonly selectedPhotoIndex = signal(0);
|
readonly selectedPhotoIndex = signal(0);
|
||||||
@@ -50,6 +66,38 @@ export class ProductDetailsContainerComponent {
|
|||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly missing = signal(false);
|
readonly missing = signal(false);
|
||||||
|
|
||||||
|
readonly activeTab = signal<ProductTabKey>('description');
|
||||||
|
|
||||||
|
readonly ratingSummary = signal<RatingSummary | null>(null);
|
||||||
|
readonly reviewsResult = signal<EngagementListResult<Review> | null>(null);
|
||||||
|
readonly questionsResult = signal<EngagementListResult<Question> | null>(null);
|
||||||
|
|
||||||
|
readonly ratingLoading = signal(false);
|
||||||
|
readonly reviewsLoading = signal(false);
|
||||||
|
readonly questionsLoading = signal(false);
|
||||||
|
readonly reviewSubmitting = signal(false);
|
||||||
|
readonly questionSubmitting = signal(false);
|
||||||
|
|
||||||
|
readonly reviewsPageSize = computed(() => this.productPageConfigState().reviews.pageSize ?? 5);
|
||||||
|
readonly questionsPageSize = computed(() => this.productPageConfigState().questions.pageSize ?? 5);
|
||||||
|
|
||||||
|
readonly tabsEnabled = computed(() => this.productPageConfigState().tabs.enabled !== false);
|
||||||
|
readonly tabs = computed<ProductTabItem[]>(() => {
|
||||||
|
const config = this.productPageConfigState();
|
||||||
|
const reviewCount = this.ratingSummary()?.totalReviews ?? this.reviewsResult()?.total ?? 0;
|
||||||
|
const questionCount = this.questionsResult()?.total ?? 0;
|
||||||
|
const defaultItems: ProductTabKey[] = ['description', 'specifications', 'reviews', 'questions', 'delivery', 'warranty'];
|
||||||
|
const enabledItems = (config.tabs.items?.length ? config.tabs.items : defaultItems)
|
||||||
|
.filter((key): key is ProductTabKey => this.isTabEnabled(key));
|
||||||
|
|
||||||
|
return enabledItems.map(key => ({
|
||||||
|
key,
|
||||||
|
label: this.getTabLabel(key),
|
||||||
|
count: key === 'reviews' ? reviewCount : key === 'questions' ? questionCount : undefined,
|
||||||
|
enabled: true
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
readonly availableColours = computed(() => {
|
readonly availableColours = computed(() => {
|
||||||
const details = this.product()?.itemDetails;
|
const details = this.product()?.itemDetails;
|
||||||
if (!details?.length) return [] as string[];
|
if (!details?.length) return [] as string[];
|
||||||
@@ -95,6 +143,8 @@ export class ProductDetailsContainerComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.ensureInitialActiveTab();
|
||||||
|
|
||||||
this.route.paramMap
|
this.route.paramMap
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe(params => this.loadProduct(Number(params.get('id'))));
|
.subscribe(params => this.loadProduct(Number(params.get('id'))));
|
||||||
@@ -113,6 +163,9 @@ export class ProductDetailsContainerComponent {
|
|||||||
this.product.set(null);
|
this.product.set(null);
|
||||||
this.relatedProducts.set([]);
|
this.relatedProducts.set([]);
|
||||||
this.selectedPhotoIndex.set(0);
|
this.selectedPhotoIndex.set(0);
|
||||||
|
this.ratingSummary.set(null);
|
||||||
|
this.reviewsResult.set(null);
|
||||||
|
this.questionsResult.set(null);
|
||||||
|
|
||||||
this.productFacade.getProduct(productId)
|
this.productFacade.getProduct(productId)
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
@@ -127,6 +180,7 @@ export class ProductDetailsContainerComponent {
|
|||||||
this.product.set(product);
|
this.product.set(product);
|
||||||
this.initVariantSelection(product);
|
this.initVariantSelection(product);
|
||||||
this.loadRelatedProducts(product);
|
this.loadRelatedProducts(product);
|
||||||
|
this.loadEngagement(product.itemID);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -182,6 +236,95 @@ export class ProductDetailsContainerComponent {
|
|||||||
this.loadProduct(productId);
|
this.loadProduct(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadReviews(page: number): void {
|
||||||
|
const current = this.product();
|
||||||
|
if (!current || this.productPageConfigState().reviews.enabled === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reviewsLoading.set(true);
|
||||||
|
this.productFacade.loadReviews(current.itemID, {
|
||||||
|
page,
|
||||||
|
pageSize: this.reviewsPageSize()
|
||||||
|
})
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: result => {
|
||||||
|
this.reviewsResult.set(result);
|
||||||
|
this.reviewsLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.reviewsResult.set({ items: [], total: 0, page, pageSize: this.reviewsPageSize() });
|
||||||
|
this.reviewsLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadQuestions(page: number): void {
|
||||||
|
const current = this.product();
|
||||||
|
if (!current || this.productPageConfigState().questions.enabled === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.questionsLoading.set(true);
|
||||||
|
this.productFacade.loadQuestions(current.itemID, {
|
||||||
|
page,
|
||||||
|
pageSize: this.questionsPageSize()
|
||||||
|
})
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: result => {
|
||||||
|
this.questionsResult.set(result);
|
||||||
|
this.questionsLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.questionsResult.set({ items: [], total: 0, page, pageSize: this.questionsPageSize() });
|
||||||
|
this.questionsLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submitReview(input: SubmitReviewInput): void {
|
||||||
|
const current = this.product();
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reviewSubmitting.set(true);
|
||||||
|
this.productFacade.submitReview(current.itemID, input)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.reviewSubmitting.set(false);
|
||||||
|
this.loadRating(current.itemID);
|
||||||
|
this.loadReviews(this.reviewsResult()?.page ?? 1);
|
||||||
|
},
|
||||||
|
error: () => this.reviewSubmitting.set(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submitQuestion(input: SubmitQuestionInput): void {
|
||||||
|
const current = this.product();
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.questionSubmitting.set(true);
|
||||||
|
this.productFacade.submitQuestion(current.itemID, input)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.questionSubmitting.set(false);
|
||||||
|
this.loadQuestions(1);
|
||||||
|
},
|
||||||
|
error: () => this.questionSubmitting.set(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isSectionEnabled(key: ProductTabKey): boolean {
|
||||||
|
return this.isTabEnabled(key);
|
||||||
|
}
|
||||||
|
|
||||||
private initVariantSelection(product: Product): void {
|
private initVariantSelection(product: Product): void {
|
||||||
const details = product.itemDetails;
|
const details = product.itemDetails;
|
||||||
if (details?.length) {
|
if (details?.length) {
|
||||||
@@ -195,6 +338,11 @@ export class ProductDetailsContainerComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private loadRelatedProducts(product: Product): void {
|
private loadRelatedProducts(product: Product): void {
|
||||||
|
if (this.productPageConfigState().relatedProducts.enabled === false) {
|
||||||
|
this.relatedProducts.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.productFacade.getRelatedProducts({
|
this.productFacade.getRelatedProducts({
|
||||||
productID: product.itemID,
|
productID: product.itemID,
|
||||||
categoryID: product.categoryID,
|
categoryID: product.categoryID,
|
||||||
@@ -205,4 +353,112 @@ export class ProductDetailsContainerComponent {
|
|||||||
error: () => this.relatedProducts.set([]),
|
error: () => this.relatedProducts.set([]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadEngagement(productID: number): void {
|
||||||
|
if (this.productPageConfigState().rating.enabled !== false) {
|
||||||
|
this.loadRating(productID);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.productPageConfigState().reviews.enabled !== false) {
|
||||||
|
this.loadReviews(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.productPageConfigState().questions.enabled !== false) {
|
||||||
|
this.loadQuestions(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadRating(productID: number): void {
|
||||||
|
this.ratingLoading.set(true);
|
||||||
|
this.productFacade.loadRating(productID)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: summary => {
|
||||||
|
this.ratingSummary.set(summary);
|
||||||
|
this.ratingLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.ratingSummary.set({
|
||||||
|
average: 0,
|
||||||
|
totalReviews: 0,
|
||||||
|
distribution: [5, 4, 3, 2, 1].map(stars => ({ stars: stars as 1 | 2 | 3 | 4 | 5, count: 0, share: 0 }))
|
||||||
|
});
|
||||||
|
this.ratingLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureInitialActiveTab(): void {
|
||||||
|
const tabs = this.tabs();
|
||||||
|
if (tabs.length > 0) {
|
||||||
|
this.activeTab.set(tabs[0].key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTabEnabled(key: ProductTabKey): boolean {
|
||||||
|
const config = this.productPageConfigState();
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'reviews':
|
||||||
|
return config.reviews.enabled !== false;
|
||||||
|
case 'questions':
|
||||||
|
return config.questions.enabled !== false;
|
||||||
|
case 'delivery':
|
||||||
|
return true;
|
||||||
|
case 'description':
|
||||||
|
case 'specifications':
|
||||||
|
case 'warranty':
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTabLabel(key: ProductTabKey): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'description':
|
||||||
|
return 'Description';
|
||||||
|
case 'specifications':
|
||||||
|
return 'Specifications';
|
||||||
|
case 'reviews':
|
||||||
|
return 'Reviews';
|
||||||
|
case 'questions':
|
||||||
|
return 'Questions';
|
||||||
|
case 'delivery':
|
||||||
|
return 'Delivery';
|
||||||
|
case 'warranty':
|
||||||
|
return 'Warranty';
|
||||||
|
default:
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProductPageConfig(): Required<ProductPageConfig> {
|
||||||
|
const bootstrapConfig = (this.configService.getBootstrapSnapshot() as any)?.productPage ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
rating: {
|
||||||
|
...DEFAULT_PRODUCT_PAGE_CONFIG.rating,
|
||||||
|
...(bootstrapConfig.rating ?? {})
|
||||||
|
},
|
||||||
|
reviews: {
|
||||||
|
...DEFAULT_PRODUCT_PAGE_CONFIG.reviews,
|
||||||
|
...(bootstrapConfig.reviews ?? {})
|
||||||
|
},
|
||||||
|
questions: {
|
||||||
|
...DEFAULT_PRODUCT_PAGE_CONFIG.questions,
|
||||||
|
...(bootstrapConfig.questions ?? {})
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
...DEFAULT_PRODUCT_PAGE_CONFIG.tabs,
|
||||||
|
...(bootstrapConfig.tabs ?? {}),
|
||||||
|
items: Array.isArray(bootstrapConfig.tabs?.items) && bootstrapConfig.tabs.items.length > 0
|
||||||
|
? bootstrapConfig.tabs.items
|
||||||
|
: DEFAULT_PRODUCT_PAGE_CONFIG.tabs.items
|
||||||
|
},
|
||||||
|
relatedProducts: {
|
||||||
|
...DEFAULT_PRODUCT_PAGE_CONFIG.relatedProducts,
|
||||||
|
...(bootstrapConfig.relatedProducts ?? {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@if (tabs.length > 0) {
|
||||||
|
<nav class="product-tabs" aria-label="Product tabs">
|
||||||
|
@for (tab of tabs; track tab.key) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab-btn"
|
||||||
|
[class.active]="activeKey === tab.key"
|
||||||
|
[disabled]="tab.enabled === false"
|
||||||
|
(click)="selectTab(tab)">
|
||||||
|
<span>{{ tab.label }}</span>
|
||||||
|
@if (tab.count != null) {
|
||||||
|
<small>{{ tab.count }}</small>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.product-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn small {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 10%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
|
||||||
|
export type ProductTabKey = 'description' | 'specifications' | 'reviews' | 'questions' | 'delivery' | 'warranty';
|
||||||
|
|
||||||
|
export interface ProductTabItem {
|
||||||
|
key: ProductTabKey;
|
||||||
|
label: string;
|
||||||
|
count?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-tabs',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './product-tabs.component.html',
|
||||||
|
styleUrls: ['./product-tabs.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ProductTabsComponent {
|
||||||
|
@Input() tabs: ProductTabItem[] = [];
|
||||||
|
@Input() activeKey: ProductTabKey | null = null;
|
||||||
|
|
||||||
|
@Output() activeKeyChange = new EventEmitter<ProductTabKey>();
|
||||||
|
|
||||||
|
selectTab(tab: ProductTabItem): void {
|
||||||
|
if (tab.enabled === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeKeyChange.emit(tab.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<article class="question-card card">
|
||||||
|
<header>
|
||||||
|
<strong>{{ question.author }}</strong>
|
||||||
|
<time>{{ question.createdAt | date:'mediumDate' }}</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="question-text">{{ question.text }}</p>
|
||||||
|
|
||||||
|
@if (question.answers.length > 0) {
|
||||||
|
<div class="answers">
|
||||||
|
@for (answer of question.answers; track answer.id) {
|
||||||
|
<div class="answer">
|
||||||
|
<div class="answer-meta">
|
||||||
|
<strong>{{ answer.author }}</strong>
|
||||||
|
<span class="seller-pill" [class.visible]="answer.isOfficialSeller">Official seller</span>
|
||||||
|
@if (answer.isAccepted) {
|
||||||
|
<span class="accepted-pill">Accepted answer</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p>{{ answer.text }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button type="button" disabled>Like ({{ question.likes }})</button>
|
||||||
|
<button type="button" disabled>Dislike ({{ question.dislikes }})</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
.question-card {
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answers {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer {
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 6%, white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seller-pill,
|
||||||
|
.accepted-pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seller-pill {
|
||||||
|
border: 1px dashed var(--border-dark);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seller-pill.visible {
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accepted-pill {
|
||||||
|
background: #e9f9ec;
|
||||||
|
color: #18723a;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer button {
|
||||||
|
min-height: 32px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
|
import { Question } from '../../../../../../core/products/models/product-engagement.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-question-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DatePipe],
|
||||||
|
templateUrl: './question-card.component.html',
|
||||||
|
styleUrls: ['./question-card.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class QuestionCardComponent {
|
||||||
|
@Input({ required: true }) question!: Question;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<form class="question-form card" (ngSubmit)="onSubmit()">
|
||||||
|
<h3>Ask a question</h3>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="question-text"
|
||||||
|
name="text"
|
||||||
|
rows="4"
|
||||||
|
[(ngModel)]="text"
|
||||||
|
placeholder="Ask about size, delivery, compatibility or usage"></textarea>
|
||||||
|
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" />
|
||||||
|
<span>Ask anonymously</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" [disabled]="submitting || !text.trim()">
|
||||||
|
@if (submitting) { Submitting... } @else { Submit question }
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.question-form {
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { SubmitQuestionInput } from '../../../../../../core/products/models/product-engagement.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-question-form',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FormsModule],
|
||||||
|
templateUrl: './question-form.component.html',
|
||||||
|
styleUrls: ['./question-form.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class QuestionFormComponent {
|
||||||
|
@Input() submitting = false;
|
||||||
|
@Output() submitQuestion = new EventEmitter<SubmitQuestionInput>();
|
||||||
|
|
||||||
|
text = '';
|
||||||
|
anonymous = false;
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
const payload: SubmitQuestionInput = {
|
||||||
|
text: this.text,
|
||||||
|
anonymous: this.anonymous
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitQuestion.emit(payload);
|
||||||
|
this.text = '';
|
||||||
|
this.anonymous = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<section class="question-list section">
|
||||||
|
<button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())">
|
||||||
|
@if (askFormVisible()) { Hide question form } @else { Ask question }
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (askFormVisible()) {
|
||||||
|
<app-question-form [submitting]="submitting" (submitQuestion)="onSubmit($event)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="skeleton-list">
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
</div>
|
||||||
|
} @else if (result?.items?.length) {
|
||||||
|
<div class="question-items">
|
||||||
|
@for (question of result!.items; track question.id) {
|
||||||
|
<app-question-card [question]="question" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="empty">No questions yet.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (result && totalPages > 1) {
|
||||||
|
<div class="pager">
|
||||||
|
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">Previous</button>
|
||||||
|
<span>Page {{ result.page }} / {{ totalPages }}</span>
|
||||||
|
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">Next</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.question-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-btn {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-items {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager button {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
height: 110px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
to { background-position: -200% 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
|
||||||
|
import { EngagementListResult, Question, SubmitQuestionInput } from '../../../../../../core/products/models/product-engagement.model';
|
||||||
|
import { QuestionCardComponent } from '../question-card/question-card.component';
|
||||||
|
import { QuestionFormComponent } from '../question-form/question-form.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-question-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [QuestionCardComponent, QuestionFormComponent],
|
||||||
|
templateUrl: './question-list.component.html',
|
||||||
|
styleUrls: ['./question-list.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class QuestionListComponent {
|
||||||
|
@Input() result: EngagementListResult<Question> | null = null;
|
||||||
|
@Input() loading = false;
|
||||||
|
@Input() submitting = false;
|
||||||
|
|
||||||
|
@Output() pageChange = new EventEmitter<number>();
|
||||||
|
@Output() submitQuestion = new EventEmitter<SubmitQuestionInput>();
|
||||||
|
|
||||||
|
readonly askFormVisible = signal(false);
|
||||||
|
|
||||||
|
get totalPages(): number {
|
||||||
|
const total = this.result?.total ?? 0;
|
||||||
|
const pageSize = this.result?.pageSize ?? 1;
|
||||||
|
return Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
previousPage(): void {
|
||||||
|
if (!this.result || this.result.page <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pageChange.emit(this.result.page - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPage(): void {
|
||||||
|
if (!this.result || this.result.page >= this.totalPages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pageChange.emit(this.result.page + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(payload: SubmitQuestionInput): void {
|
||||||
|
this.submitQuestion.emit(payload);
|
||||||
|
this.askFormVisible.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<section class="rating-summary card section">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="rating-skeleton">
|
||||||
|
<div class="line w40"></div>
|
||||||
|
<div class="line w60"></div>
|
||||||
|
<div class="line w100"></div>
|
||||||
|
<div class="line w100"></div>
|
||||||
|
</div>
|
||||||
|
} @else if (summary) {
|
||||||
|
<div class="headline">
|
||||||
|
<strong class="average">{{ summary.average | number:'1.1-1' }}</strong>
|
||||||
|
<app-stars [rating]="summary.average" size="lg" />
|
||||||
|
<span class="total">{{ summary.totalReviews }} reviews</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distribution">
|
||||||
|
@for (entry of summary.distribution; track entry.stars) {
|
||||||
|
<div class="distribution-row">
|
||||||
|
<span>{{ entry.stars }}★</span>
|
||||||
|
<progress [value]="entry.share" max="1"></progress>
|
||||||
|
<small>{{ entry.count }} ({{ entry.share | percent:'1.0-0' }})</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.rating-summary {
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.average {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-row progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-row progress::-webkit-progress-bar {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-row progress::-webkit-progress-value {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w40 { width: 40%; }
|
||||||
|
.w60 { width: 60%; }
|
||||||
|
.w100 { width: 100%; }
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
to { background-position: -200% 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { DecimalPipe, PercentPipe } from '@angular/common';
|
||||||
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
|
import { RatingSummary } from '../../../../../../core/products/models/product-engagement.model';
|
||||||
|
import { StarsComponent } from '../stars/stars.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-rating-summary',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DecimalPipe, PercentPipe, StarsComponent],
|
||||||
|
templateUrl: './rating-summary.component.html',
|
||||||
|
styleUrls: ['./rating-summary.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class RatingSummaryComponent {
|
||||||
|
@Input() summary: RatingSummary | null = null;
|
||||||
|
@Input() loading = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<article class="review-card card">
|
||||||
|
<header class="review-header">
|
||||||
|
<app-stars [rating]="review.rating" size="sm" />
|
||||||
|
<strong class="author">{{ review.author }}</strong>
|
||||||
|
<span class="pill">Verified purchase</span>
|
||||||
|
<time>{{ review.createdAt | date:'mediumDate' }}</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (review.title) {
|
||||||
|
<h4>{{ review.title }}</h4>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p>{{ review.text }}</p>
|
||||||
|
|
||||||
|
<footer class="review-actions">
|
||||||
|
<button type="button" disabled>Like ({{ review.likes }})</button>
|
||||||
|
<button type="button" disabled>Dislike ({{ review.dislikes }})</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
.review-card {
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
border: 1px dashed var(--border-dark);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-actions button {
|
||||||
|
min-height: 32px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
|
import { Review } from '../../../../../../core/products/models/product-engagement.model';
|
||||||
|
import { StarsComponent } from '../stars/stars.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-review-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DatePipe, StarsComponent],
|
||||||
|
templateUrl: './review-card.component.html',
|
||||||
|
styleUrls: ['./review-card.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ReviewCardComponent {
|
||||||
|
@Input({ required: true }) review!: Review;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<form class="review-form card" (ngSubmit)="onSubmit()">
|
||||||
|
<h3>Leave a review</h3>
|
||||||
|
|
||||||
|
<label>Rating</label>
|
||||||
|
<app-star-selector [(rating)]="rating" />
|
||||||
|
|
||||||
|
<label for="review-title">Title</label>
|
||||||
|
<input id="review-title" name="title" [(ngModel)]="title" placeholder="Short headline" maxlength="120" />
|
||||||
|
|
||||||
|
<label for="review-text">Review</label>
|
||||||
|
<textarea id="review-text" name="text" [(ngModel)]="text" rows="4" placeholder="Describe your experience"></textarea>
|
||||||
|
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" />
|
||||||
|
<span>Submit anonymously</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="upload-placeholder">Photo upload is planned for a future sprint.</p>
|
||||||
|
|
||||||
|
<button type="submit" [disabled]="submitting || !rating || !text.trim()">
|
||||||
|
@if (submitting) { Submitting... } @else { Submit review }
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.review-form {
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-placeholder {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { SubmitReviewInput } from '../../../../../../core/products/models/product-engagement.model';
|
||||||
|
import { StarSelectorComponent } from '../star-selector/star-selector.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-review-form',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FormsModule, StarSelectorComponent],
|
||||||
|
templateUrl: './review-form.component.html',
|
||||||
|
styleUrls: ['./review-form.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ReviewFormComponent {
|
||||||
|
@Input() submitting = false;
|
||||||
|
@Output() submitReview = new EventEmitter<SubmitReviewInput>();
|
||||||
|
|
||||||
|
rating = 0;
|
||||||
|
title = '';
|
||||||
|
text = '';
|
||||||
|
anonymous = false;
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
const payload: SubmitReviewInput = {
|
||||||
|
rating: this.rating,
|
||||||
|
title: this.title,
|
||||||
|
text: this.text,
|
||||||
|
anonymous: this.anonymous
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.rating || !payload.text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitReview.emit(payload);
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset(): void {
|
||||||
|
this.rating = 0;
|
||||||
|
this.title = '';
|
||||||
|
this.text = '';
|
||||||
|
this.anonymous = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<section class="review-list section">
|
||||||
|
@if (showSummary) {
|
||||||
|
<app-rating-summary [summary]="ratingSummary" [loading]="loading" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-review-form [submitting]="submitting" (submitReview)="submitReview.emit($event)" />
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="skeleton-list">
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
</div>
|
||||||
|
} @else if (result?.items?.length) {
|
||||||
|
<div class="review-items">
|
||||||
|
@for (review of result!.items; track review.id) {
|
||||||
|
<app-review-card [review]="review" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="empty">No reviews yet.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (result && totalPages > 1) {
|
||||||
|
<div class="pager">
|
||||||
|
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">Previous</button>
|
||||||
|
<span>Page {{ result.page }} / {{ totalPages }}</span>
|
||||||
|
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">Next</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.review-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-items {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager button {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
height: 128px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
to { background-position: -200% 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { EngagementListResult, Review, SubmitReviewInput } from '../../../../../../core/products/models/product-engagement.model';
|
||||||
|
import { RatingSummary } from '../../../../../../core/products/models/product-engagement.model';
|
||||||
|
import { RatingSummaryComponent } from '../rating-summary/rating-summary.component';
|
||||||
|
import { ReviewCardComponent } from '../review-card/review-card.component';
|
||||||
|
import { ReviewFormComponent } from '../review-form/review-form.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-review-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RatingSummaryComponent, ReviewCardComponent, ReviewFormComponent],
|
||||||
|
templateUrl: './review-list.component.html',
|
||||||
|
styleUrls: ['./review-list.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ReviewListComponent {
|
||||||
|
@Input() result: EngagementListResult<Review> | null = null;
|
||||||
|
@Input() ratingSummary: RatingSummary | null = null;
|
||||||
|
@Input() loading = false;
|
||||||
|
@Input() submitting = false;
|
||||||
|
@Input() showSummary = true;
|
||||||
|
|
||||||
|
@Output() pageChange = new EventEmitter<number>();
|
||||||
|
@Output() submitReview = new EventEmitter<SubmitReviewInput>();
|
||||||
|
|
||||||
|
get totalPages(): number {
|
||||||
|
const total = this.result?.total ?? 0;
|
||||||
|
const pageSize = this.result?.pageSize ?? 1;
|
||||||
|
return Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
previousPage(): void {
|
||||||
|
if (!this.result || this.result.page <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pageChange.emit(this.result.page - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPage(): void {
|
||||||
|
if (!this.result || this.result.page >= this.totalPages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pageChange.emit(this.result.page + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<div class="star-selector" role="radiogroup" aria-label="Select rating">
|
||||||
|
@for (star of stars; track star) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="star-btn"
|
||||||
|
[class.selected]="star <= rating"
|
||||||
|
[attr.aria-checked]="star === rating"
|
||||||
|
role="radio"
|
||||||
|
(click)="ratingChange.emit(star)">
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.star-selector {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-btn {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #cad3d2;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-btn.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-star-selector',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './star-selector.component.html',
|
||||||
|
styleUrls: ['./star-selector.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class StarSelectorComponent {
|
||||||
|
@Input() rating = 0;
|
||||||
|
@Output() ratingChange = new EventEmitter<number>();
|
||||||
|
|
||||||
|
readonly stars = [1, 2, 3, 4, 5];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<span class="stars" [class.sm]="size === 'sm'" [class.lg]="size === 'lg'" aria-label="Rating stars">
|
||||||
|
@for (star of stars; track star) {
|
||||||
|
<span class="star" [class.filled]="star <= rating">★</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.stars {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star {
|
||||||
|
color: #cdd6d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star.filled {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars.sm {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars.lg {
|
||||||
|
font-size: 1.28rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-stars',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './stars.component.html',
|
||||||
|
styleUrls: ['./stars.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class StarsComponent {
|
||||||
|
@Input() rating = 0;
|
||||||
|
@Input() max = 5;
|
||||||
|
@Input() size: 'sm' | 'md' | 'lg' = 'md';
|
||||||
|
|
||||||
|
get stars(): number[] {
|
||||||
|
return Array.from({ length: this.max }, (_, index) => index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { LocalizationConfig } from './localization.model';
|
|||||||
import { NavigationConfig } from './navigation.model';
|
import { NavigationConfig } from './navigation.model';
|
||||||
import { PageConfig } from './page.model';
|
import { PageConfig } from './page.model';
|
||||||
import { PermissionsConfig } from './permissions.model';
|
import { PermissionsConfig } from './permissions.model';
|
||||||
|
import { ProductPageConfig } from './product-page-config.model';
|
||||||
import { SeoConfig } from './seo.model';
|
import { SeoConfig } from './seo.model';
|
||||||
import { StaticPagesConfig } from './static-page.model';
|
import { StaticPagesConfig } from './static-page.model';
|
||||||
import { TenantConfig } from './tenant.model';
|
import { TenantConfig } from './tenant.model';
|
||||||
@@ -29,6 +30,7 @@ export interface BootstrapConfig {
|
|||||||
layout?: PlatformLayoutConfig;
|
layout?: PlatformLayoutConfig;
|
||||||
navigation: NavigationConfig;
|
navigation: NavigationConfig;
|
||||||
footer?: FooterConfig;
|
footer?: FooterConfig;
|
||||||
|
productPage?: ProductPageConfig;
|
||||||
pages: PageConfig[];
|
pages: PageConfig[];
|
||||||
staticPages?: StaticPagesConfig;
|
staticPages?: StaticPagesConfig;
|
||||||
widgetRegistry?: WidgetRegistryConfig;
|
widgetRegistry?: WidgetRegistryConfig;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export * from './localization.model';
|
|||||||
export * from './navigation.model';
|
export * from './navigation.model';
|
||||||
export * from './page.model';
|
export * from './page.model';
|
||||||
export * from './permissions.model';
|
export * from './permissions.model';
|
||||||
|
export * from './product-page-config.model';
|
||||||
export * from './section.model';
|
export * from './section.model';
|
||||||
export * from './seo.model';
|
export * from './seo.model';
|
||||||
export * from './static-page.model';
|
export * from './static-page.model';
|
||||||
|
|||||||
50
src/app/shared/models/config/product-page-config.model.ts
Normal file
50
src/app/shared/models/config/product-page-config.model.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export interface ProductSectionConfig {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductRatingConfig extends ProductSectionConfig {}
|
||||||
|
|
||||||
|
export interface ProductReviewsConfig extends ProductSectionConfig {
|
||||||
|
pageSize?: number;
|
||||||
|
showSummary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductQuestionsConfig extends ProductSectionConfig {
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductTabsConfig extends ProductSectionConfig {
|
||||||
|
items?: Array<'description' | 'specifications' | 'reviews' | 'questions' | 'delivery' | 'warranty'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductRelatedConfig extends ProductSectionConfig {}
|
||||||
|
|
||||||
|
export interface ProductPageConfig {
|
||||||
|
rating?: ProductRatingConfig;
|
||||||
|
reviews?: ProductReviewsConfig;
|
||||||
|
questions?: ProductQuestionsConfig;
|
||||||
|
tabs?: ProductTabsConfig;
|
||||||
|
relatedProducts?: ProductRelatedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PRODUCT_PAGE_CONFIG: Required<ProductPageConfig> = {
|
||||||
|
rating: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
reviews: {
|
||||||
|
enabled: true,
|
||||||
|
pageSize: 5,
|
||||||
|
showSummary: true
|
||||||
|
},
|
||||||
|
questions: {
|
||||||
|
enabled: true,
|
||||||
|
pageSize: 5
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
enabled: true,
|
||||||
|
items: ['description', 'specifications', 'reviews', 'questions', 'delivery', 'warranty']
|
||||||
|
},
|
||||||
|
relatedProducts: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { SectionConfig } from '../../shared/models/config';
|
import { SectionConfig } from '../../shared/models/config';
|
||||||
import { CatalogCategoryGridComponent } from '../../features/website/catalog/components/category-grid/category-grid.component';
|
import { CatalogCategoryGridComponent } from '../../features/website/catalog/components/category-grid/category-grid.component';
|
||||||
import { CategoriesWidgetData } from '../contracts/widget-data.contract';
|
import { CategoriesWidgetData } from '../contracts/widget-data.contract';
|
||||||
import { Category } from '../../core/categories/models/category-domain.model';
|
|
||||||
import { LanguageService } from '../../services/language.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-categories-widget',
|
selector: 'app-categories-widget',
|
||||||
@@ -38,15 +35,11 @@ import { LanguageService } from '../../services/language.service';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class CategoriesWidgetComponent {
|
export class CategoriesWidgetComponent {
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly languageService = inject(LanguageService);
|
|
||||||
|
|
||||||
@Input() section: SectionConfig | null = null;
|
@Input() section: SectionConfig | null = null;
|
||||||
@Input() data: CategoriesWidgetData | null = null;
|
@Input() data: CategoriesWidgetData | null = null;
|
||||||
@Output() categorySelected = new EventEmitter<Category>();
|
@Output() categorySelected = new EventEmitter<unknown>();
|
||||||
|
|
||||||
onCategorySelected(category: Category): void {
|
onCategorySelected(category: unknown): void {
|
||||||
this.categorySelected.emit(category);
|
this.categorySelected.emit(category);
|
||||||
this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`, category.id]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,6 +246,27 @@
|
|||||||
},
|
},
|
||||||
"legalPageKeys": ["about-us", "privacy-policy", "terms-of-service"]
|
"legalPageKeys": ["about-us", "privacy-policy", "terms-of-service"]
|
||||||
},
|
},
|
||||||
|
"productPage": {
|
||||||
|
"rating": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"reviews": {
|
||||||
|
"enabled": true,
|
||||||
|
"pageSize": 5,
|
||||||
|
"showSummary": true
|
||||||
|
},
|
||||||
|
"questions": {
|
||||||
|
"enabled": true,
|
||||||
|
"pageSize": 5
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"enabled": true,
|
||||||
|
"items": ["description", "specifications", "reviews", "questions", "delivery", "warranty"]
|
||||||
|
},
|
||||||
|
"relatedProducts": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"widgetRegistry": {
|
"widgetRegistry": {
|
||||||
"manifestUrl": "/assets/mock/bootstrap/widget-manifest.json"
|
"manifestUrl": "/assets/mock/bootstrap/widget-manifest.json"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user