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:
|
||||
- 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
|
||||
Purpose:
|
||||
- category tree retrieval
|
||||
|
||||
@@ -296,6 +296,23 @@
|
||||
"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": [
|
||||
{
|
||||
"id": "page-home",
|
||||
|
||||
@@ -54,6 +54,7 @@ Bootstrap JSON является главным конфигурационным
|
||||
- branding
|
||||
- navigation
|
||||
- footer
|
||||
- productPage
|
||||
- staticPages
|
||||
- widgetRegistry
|
||||
- visibility
|
||||
@@ -69,6 +70,18 @@ Bootstrap JSON является главным конфигурационным
|
||||
- Для виджетов допустимы metadata поля: order, padding, visibility.desktop/tablet/mobile.
|
||||
- Для footer links/legal/payout icons источник истины — bootstrap JSON.
|
||||
- Для 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
|
||||
```json
|
||||
|
||||
@@ -24,6 +24,21 @@
|
||||
- В ответах на списки должна поддерживаться пагинация.
|
||||
- Ошибки 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 ответа: категории
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -25,11 +25,30 @@
|
||||
- stockStatus
|
||||
- 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 должен ссылаться на существующую категорию.
|
||||
- Виджет не изменяет товарные данные, только отображает.
|
||||
- Frontend feature-слой работает только через ProductFacade.
|
||||
- DTO/API shape не импортируется в feature components.
|
||||
- reviews/questions не хранятся в bootstrap, только их feature-конфиг.
|
||||
|
||||
## Пример JSON товара
|
||||
```json
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- Выдача bootstrap.json для tenant.
|
||||
- API категорий, товаров, карточек, поисковых выборок.
|
||||
- Выдача навигации, статических страниц и feature flags.
|
||||
- Product Engagement API для рейтинга, отзывов и вопросов.
|
||||
|
||||
### Контракт статических страниц
|
||||
Backend должен поддерживать формат:
|
||||
@@ -26,8 +27,19 @@ Backend должен поддерживать формат:
|
||||
- Bootstrap контракт со schemaVersion.
|
||||
- Категории: id/title/parentId/visible/priority.
|
||||
- Товары: itemID/name/price/currency/categoryID/visible.
|
||||
- Product Engagement:
|
||||
- RatingSummary (average, totalReviews, distribution)
|
||||
- Review (id, rating, text, createdAt, author)
|
||||
- Question (id, text, createdAt, answers)
|
||||
- Унифицированный формат ошибок 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-контракты
|
||||
- Персонализированные рекомендации.
|
||||
- Расширенные 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 { PRODUCT_DATA_PROVIDER } from './product-data-provider.token';
|
||||
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' })
|
||||
export class ProductDataService {
|
||||
@@ -38,4 +39,24 @@ export class ProductDataService {
|
||||
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult> {
|
||||
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 { Observable, map } from 'rxjs';
|
||||
import { ApiService } from '../../../services';
|
||||
import { AuthService } from '../../../services/auth.service';
|
||||
import { CategoryService } from '../../categories/category.service';
|
||||
import { ProductDataProvider } from './product-data-provider.interface';
|
||||
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' })
|
||||
export class ApiProductDataProvider implements ProductDataProvider {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly categoryService: CategoryService
|
||||
private readonly categoryService: CategoryService,
|
||||
private readonly authService: AuthService
|
||||
) {}
|
||||
|
||||
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) {
|
||||
return {
|
||||
categoryIDs: query.categoryIDs,
|
||||
@@ -76,4 +116,112 @@ export class ApiProductDataProvider implements ProductDataProvider {
|
||||
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 { 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 {
|
||||
getProducts(query?: ProductListQuery): Observable<ProductListResult>;
|
||||
@@ -10,4 +11,9 @@ export interface ProductDataProvider {
|
||||
getLatestProducts(query?: ProductListQuery): Observable<ProductListResult>;
|
||||
getProductsByCategory(categoryID: number, query?: ProductListQuery): 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,
|
||||
RelatedProductsQuery,
|
||||
} 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' })
|
||||
export class ProductFacade {
|
||||
@@ -45,4 +46,24 @@ export class ProductFacade {
|
||||
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult> {
|
||||
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) {
|
||||
<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) {
|
||||
<p>{{ product.description }}</p>
|
||||
}
|
||||
|
||||
@@ -2,55 +2,20 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1e3c38;
|
||||
}
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #1e3c38;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #697777;
|
||||
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 {
|
||||
@Input({ required: true }) product!: Product;
|
||||
@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,13 +58,100 @@
|
||||
</div>
|
||||
</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)" />
|
||||
|
||||
<app-related-products
|
||||
[products]="relatedProducts()"
|
||||
(productSelected)="selectRelatedProduct($event)"
|
||||
(addToCart)="addRelatedToCart($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
|
||||
[products]="relatedProducts()"
|
||||
(productSelected)="selectRelatedProduct($event)"
|
||||
(addToCart)="addRelatedToCart($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</main>
|
||||
|
||||
@@ -20,6 +20,21 @@
|
||||
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-message {
|
||||
min-height: 420px;
|
||||
|
||||
@@ -2,18 +2,26 @@ import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signa
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
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 { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
|
||||
import { CartService } from '../../../../services';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { DEFAULT_PRODUCT_PAGE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
|
||||
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
||||
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
|
||||
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
|
||||
import { ProductInformationComponent } from '../components/product-information/product-information.component';
|
||||
import { ProductDescriptionComponent } from '../components/product-description/product-description.component';
|
||||
import { ProductSpecificationsComponent } from '../components/product-specifications/product-specifications.component';
|
||||
import { ProductWarrantyComponent } from '../components/product-warranty/product-warranty.component';
|
||||
import { RelatedProductsComponent } from '../components/related-products/related-products.component';
|
||||
import { ProductVariantSelectorComponent } from '../components/variant-selector/variant-selector.component';
|
||||
import { 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({
|
||||
selector: 'app-product-details-container',
|
||||
@@ -27,6 +35,11 @@ import { ProductVariantSelectorComponent } from '../components/variant-selector/
|
||||
ProductVariantSelectorComponent,
|
||||
ProductDeliveryInformationComponent,
|
||||
ProductDescriptionComponent,
|
||||
ProductSpecificationsComponent,
|
||||
ProductWarrantyComponent,
|
||||
ProductTabsComponent,
|
||||
ReviewListComponent,
|
||||
QuestionListComponent,
|
||||
RelatedProductsComponent,
|
||||
],
|
||||
templateUrl: './product-details-container.component.html',
|
||||
@@ -37,10 +50,13 @@ export class ProductDetailsContainerComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly configService = inject(ConfigService);
|
||||
private readonly productFacade = inject(ProductFacade);
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
|
||||
|
||||
readonly product = signal<Product | null>(null);
|
||||
readonly relatedProducts = signal<Product[]>([]);
|
||||
readonly selectedPhotoIndex = signal(0);
|
||||
@@ -50,6 +66,38 @@ export class ProductDetailsContainerComponent {
|
||||
readonly error = signal<string | null>(null);
|
||||
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(() => {
|
||||
const details = this.product()?.itemDetails;
|
||||
if (!details?.length) return [] as string[];
|
||||
@@ -95,6 +143,8 @@ export class ProductDetailsContainerComponent {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.ensureInitialActiveTab();
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(params => this.loadProduct(Number(params.get('id'))));
|
||||
@@ -113,6 +163,9 @@ export class ProductDetailsContainerComponent {
|
||||
this.product.set(null);
|
||||
this.relatedProducts.set([]);
|
||||
this.selectedPhotoIndex.set(0);
|
||||
this.ratingSummary.set(null);
|
||||
this.reviewsResult.set(null);
|
||||
this.questionsResult.set(null);
|
||||
|
||||
this.productFacade.getProduct(productId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
@@ -127,6 +180,7 @@ export class ProductDetailsContainerComponent {
|
||||
this.product.set(product);
|
||||
this.initVariantSelection(product);
|
||||
this.loadRelatedProducts(product);
|
||||
this.loadEngagement(product.itemID);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
@@ -182,6 +236,95 @@ export class ProductDetailsContainerComponent {
|
||||
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 {
|
||||
const details = product.itemDetails;
|
||||
if (details?.length) {
|
||||
@@ -195,6 +338,11 @@ export class ProductDetailsContainerComponent {
|
||||
}
|
||||
|
||||
private loadRelatedProducts(product: Product): void {
|
||||
if (this.productPageConfigState().relatedProducts.enabled === false) {
|
||||
this.relatedProducts.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.productFacade.getRelatedProducts({
|
||||
productID: product.itemID,
|
||||
categoryID: product.categoryID,
|
||||
@@ -205,4 +353,112 @@ export class ProductDetailsContainerComponent {
|
||||
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 { PageConfig } from './page.model';
|
||||
import { PermissionsConfig } from './permissions.model';
|
||||
import { ProductPageConfig } from './product-page-config.model';
|
||||
import { SeoConfig } from './seo.model';
|
||||
import { StaticPagesConfig } from './static-page.model';
|
||||
import { TenantConfig } from './tenant.model';
|
||||
@@ -29,6 +30,7 @@ export interface BootstrapConfig {
|
||||
layout?: PlatformLayoutConfig;
|
||||
navigation: NavigationConfig;
|
||||
footer?: FooterConfig;
|
||||
productPage?: ProductPageConfig;
|
||||
pages: PageConfig[];
|
||||
staticPages?: StaticPagesConfig;
|
||||
widgetRegistry?: WidgetRegistryConfig;
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './localization.model';
|
||||
export * from './navigation.model';
|
||||
export * from './page.model';
|
||||
export * from './permissions.model';
|
||||
export * from './product-page-config.model';
|
||||
export * from './section.model';
|
||||
export * from './seo.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 { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { CatalogCategoryGridComponent } from '../../features/website/catalog/components/category-grid/category-grid.component';
|
||||
import { CategoriesWidgetData } from '../contracts/widget-data.contract';
|
||||
import { Category } from '../../core/categories/models/category-domain.model';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-categories-widget',
|
||||
@@ -38,15 +35,11 @@ import { LanguageService } from '../../services/language.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CategoriesWidgetComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
@Input() section: SectionConfig | 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.router.navigate([`/${this.languageService.currentLanguage()}/catalog`, category.id]);
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,27 @@
|
||||
},
|
||||
"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": {
|
||||
"manifestUrl": "/assets/mock/bootstrap/widget-manifest.json"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user