feat(product): implement sprint 9 product engagement module

This commit is contained in:
sdarbinyan
2026-07-09 00:45:36 +04:00
parent 10251f2fc6
commit 3ef0bd711d
59 changed files with 2060 additions and 74 deletions

View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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"
}, },