diff --git a/Sprint-9-Product-Engagement-Report.md b/Sprint-9-Product-Engagement-Report.md new file mode 100644 index 0000000..f4ce7d2 --- /dev/null +++ b/Sprint-9-Product-Engagement-Report.md @@ -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` + - `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. diff --git a/docs/backend-platform/business-apis.md b/docs/backend-platform/business-apis.md index 58ecd14..f242d9c 100644 --- a/docs/backend-platform/business-apis.md +++ b/docs/backend-platform/business-apis.md @@ -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 diff --git a/docs/platform/00-bootstrap-example.md b/docs/platform/00-bootstrap-example.md index 8ab5309..068303f 100644 --- a/docs/platform/00-bootstrap-example.md +++ b/docs/platform/00-bootstrap-example.md @@ -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", diff --git a/docs/platform/02-bootstrap-json-spec.md b/docs/platform/02-bootstrap-json-spec.md index 062abe7..8504559 100644 --- a/docs/platform/02-bootstrap-json-spec.md +++ b/docs/platform/02-bootstrap-json-spec.md @@ -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 diff --git a/docs/platform/06-api-contracts.md b/docs/platform/06-api-contracts.md index f453e28..da3182c 100644 --- a/docs/platform/06-api-contracts.md +++ b/docs/platform/06-api-contracts.md @@ -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 { diff --git a/docs/platform/10-product-domain.md b/docs/platform/10-product-domain.md index 74aa27d..ef4c684 100644 --- a/docs/platform/10-product-domain.md +++ b/docs/platform/10-product-domain.md @@ -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 diff --git a/docs/platform/13-backend-requirements.md b/docs/platform/13-backend-requirements.md index 0ddd219..7c6ad2b 100644 --- a/docs/platform/13-backend-requirements.md +++ b/docs/platform/13-backend-requirements.md @@ -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. diff --git a/src/app/core/products/models/product-engagement.model.ts b/src/app/core/products/models/product-engagement.model.ts new file mode 100644 index 0000000..2ee491f --- /dev/null +++ b/src/app/core/products/models/product-engagement.model.ts @@ -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 { + 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; +} diff --git a/src/app/core/products/product-data.service.ts b/src/app/core/products/product-data.service.ts index 568e5d2..d5f23be 100644 --- a/src/app/core/products/product-data.service.ts +++ b/src/app/core/products/product-data.service.ts @@ -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 { return this.provider.getRelatedProducts(query); } + + loadRating(productID: number): Observable { + return this.provider.loadRating(productID); + } + + loadReviews(productID: number, query?: EngagementListQuery): Observable> { + return this.provider.loadReviews(productID, query); + } + + loadQuestions(productID: number, query?: EngagementListQuery): Observable> { + return this.provider.loadQuestions(productID, query); + } + + submitReview(productID: number, input: SubmitReviewInput): Observable { + return this.provider.submitReview(productID, input); + } + + submitQuestion(productID: number, input: SubmitQuestionInput): Observable { + return this.provider.submitQuestion(productID, input); + } } diff --git a/src/app/core/products/providers/api-product-data.provider.ts b/src/app/core/products/providers/api-product-data.provider.ts index 20a3aa0..b3d752f 100644 --- a/src/app/core/products/providers/api-product-data.provider.ts +++ b/src/app/core/products/providers/api-product-data.provider.ts @@ -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 { @@ -58,6 +61,43 @@ export class ApiProductDataProvider implements ProductDataProvider { ); } + loadRating(productID: number): Observable { + return this.apiService.getItem(productID).pipe( + map(item => this.toRatingSummary(this.toReviews(item))) + ); + } + + loadReviews(productID: number, query: EngagementListQuery = {}): Observable> { + return this.apiService.getItem(productID).pipe( + map(item => this.paginate(this.toReviews(item), query)) + ); + } + + loadQuestions(productID: number, query: EngagementListQuery = {}): Observable> { + return this.apiService.getItem(productID).pipe( + map(item => this.paginate(this.toQuestions(item), query)) + ); + } + + submitReview(productID: number, input: SubmitReviewInput): Observable { + 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 { + 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 = { 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(items: T[], query: EngagementListQuery): EngagementListResult { + 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 + }; + } } diff --git a/src/app/core/products/providers/product-data-provider.interface.ts b/src/app/core/products/providers/product-data-provider.interface.ts index f7183ab..5a34a22 100644 --- a/src/app/core/products/providers/product-data-provider.interface.ts +++ b/src/app/core/products/providers/product-data-provider.interface.ts @@ -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; @@ -10,4 +11,9 @@ export interface ProductDataProvider { getLatestProducts(query?: ProductListQuery): Observable; getProductsByCategory(categoryID: number, query?: ProductListQuery): Observable; getRelatedProducts(query: RelatedProductsQuery): Observable; + loadRating(productID: number): Observable; + loadReviews(productID: number, query?: EngagementListQuery): Observable>; + loadQuestions(productID: number, query?: EngagementListQuery): Observable>; + submitReview(productID: number, input: SubmitReviewInput): Observable; + submitQuestion(productID: number, input: SubmitQuestionInput): Observable; } diff --git a/src/app/facades/platform/product.facade.ts b/src/app/facades/platform/product.facade.ts index f3babd6..3833a74 100644 --- a/src/app/facades/platform/product.facade.ts +++ b/src/app/facades/platform/product.facade.ts @@ -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 { return this.productData.getRelatedProducts(query); } + + loadRating(productID: number): Observable { + return this.productData.loadRating(productID); + } + + loadReviews(productID: number, query?: EngagementListQuery): Observable> { + return this.productData.loadReviews(productID, query); + } + + loadQuestions(productID: number, query?: EngagementListQuery): Observable> { + return this.productData.loadQuestions(productID, query); + } + + submitReview(productID: number, input: SubmitReviewInput): Observable { + return this.productData.submitReview(productID, input); + } + + submitQuestion(productID: number, input: SubmitQuestionInput): Observable { + return this.productData.submitQuestion(productID, input); + } } diff --git a/src/app/features/website/product/components/product-description/product-description.component.html b/src/app/features/website/product/components/product-description/product-description.component.html index eef0350..cedea4c 100644 --- a/src/app/features/website/product/components/product-description/product-description.component.html +++ b/src/app/features/website/product/components/product-description/product-description.component.html @@ -3,18 +3,6 @@ @if (simpleDescription) {

{{ simpleDescription }}

- } - - @if (descriptionFields.length > 0) { -

{{ 'itemDetail.specifications' | translate }}

-
- @for (field of descriptionFields; track field.key) { -
-
{{ field.key }}
-
{{ field.value }}
-
- } -
} @else if (product.description) {

{{ product.description }}

} diff --git a/src/app/features/website/product/components/product-description/product-description.component.scss b/src/app/features/website/product/components/product-description/product-description.component.scss index 116cb2e..83db4f4 100644 --- a/src/app/features/website/product/components/product-description/product-description.component.scss +++ b/src/app/features/website/product/components/product-description/product-description.component.scss @@ -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; - } } diff --git a/src/app/features/website/product/components/product-description/product-description.component.ts b/src/app/features/website/product/components/product-description/product-description.component.ts index 5a10eca..968ecbc 100644 --- a/src/app/features/website/product/components/product-description/product-description.component.ts +++ b/src/app/features/website/product/components/product-description/product-description.component.ts @@ -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 ?? []; - } } diff --git a/src/app/features/website/product/components/product-specifications/product-specifications.component.html b/src/app/features/website/product/components/product-specifications/product-specifications.component.html new file mode 100644 index 0000000..ef752b6 --- /dev/null +++ b/src/app/features/website/product/components/product-specifications/product-specifications.component.html @@ -0,0 +1,16 @@ +
+

Specifications

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

No specifications provided yet.

+ } +
diff --git a/src/app/features/website/product/components/product-specifications/product-specifications.component.scss b/src/app/features/website/product/components/product-specifications/product-specifications.component.scss new file mode 100644 index 0000000..492ee94 --- /dev/null +++ b/src/app/features/website/product/components/product-specifications/product-specifications.component.scss @@ -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; + } +} diff --git a/src/app/features/website/product/components/product-specifications/product-specifications.component.ts b/src/app/features/website/product/components/product-specifications/product-specifications.component.ts new file mode 100644 index 0000000..336490c --- /dev/null +++ b/src/app/features/website/product/components/product-specifications/product-specifications.component.ts @@ -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 ?? []; + } +} diff --git a/src/app/features/website/product/components/product-warranty/product-warranty.component.html b/src/app/features/website/product/components/product-warranty/product-warranty.component.html new file mode 100644 index 0000000..7744344 --- /dev/null +++ b/src/app/features/website/product/components/product-warranty/product-warranty.component.html @@ -0,0 +1,8 @@ +
+

Warranty and returns

+
    +
  • Warranty terms are provided by the seller and local law.
  • +
  • Return eligibility depends on item condition and category.
  • +
  • Detailed return instructions are available after purchase.
  • +
+
diff --git a/src/app/features/website/product/components/product-warranty/product-warranty.component.scss b/src/app/features/website/product/components/product-warranty/product-warranty.component.scss new file mode 100644 index 0000000..7ae0cae --- /dev/null +++ b/src/app/features/website/product/components/product-warranty/product-warranty.component.scss @@ -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); +} diff --git a/src/app/features/website/product/components/product-warranty/product-warranty.component.ts b/src/app/features/website/product/components/product-warranty/product-warranty.component.ts new file mode 100644 index 0000000..ff2bb4f --- /dev/null +++ b/src/app/features/website/product/components/product-warranty/product-warranty.component.ts @@ -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 {} diff --git a/src/app/features/website/product/containers/product-details-container.component.html b/src/app/features/website/product/containers/product-details-container.component.html index f885c6b..6240bb2 100644 --- a/src/app/features/website/product/containers/product-details-container.component.html +++ b/src/app/features/website/product/containers/product-details-container.component.html @@ -58,13 +58,100 @@ - + @if (tabsEnabled()) { +
+ - +
+ @switch (activeTab()) { + @case ('description') { + + } + + @case ('specifications') { + + } + + @case ('reviews') { + + } + + @case ('questions') { + + } + + @case ('delivery') { + + } + + @case ('warranty') { + + } + } +
+
+ } @else { +
+ @if (isSectionEnabled('description')) { + + } + + @if (isSectionEnabled('specifications')) { + + } + + @if (isSectionEnabled('reviews')) { + + } + + @if (isSectionEnabled('questions')) { + + } + + @if (isSectionEnabled('delivery')) { + + } + + @if (isSectionEnabled('warranty')) { + + } +
+ } + + @if (productPageConfigState().relatedProducts.enabled !== false) { + + } } } diff --git a/src/app/features/website/product/containers/product-details-container.component.scss b/src/app/features/website/product/containers/product-details-container.component.scss index 6cbdb1b..b9eb1d6 100644 --- a/src/app/features/website/product/containers/product-details-container.component.scss +++ b/src/app/features/website/product/containers/product-details-container.component.scss @@ -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; diff --git a/src/app/features/website/product/containers/product-details-container.component.ts b/src/app/features/website/product/containers/product-details-container.component.ts index 44896ac..b27abfe 100644 --- a/src/app/features/website/product/containers/product-details-container.component.ts +++ b/src/app/features/website/product/containers/product-details-container.component.ts @@ -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>(this.resolveProductPageConfig()); + readonly product = signal(null); readonly relatedProducts = signal([]); readonly selectedPhotoIndex = signal(0); @@ -50,6 +66,38 @@ export class ProductDetailsContainerComponent { readonly error = signal(null); readonly missing = signal(false); + readonly activeTab = signal('description'); + + readonly ratingSummary = signal(null); + readonly reviewsResult = signal | null>(null); + readonly questionsResult = signal | 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(() => { + 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 { + 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 ?? {}) + } + }; + } } diff --git a/src/app/features/website/product/engagement/components/product-tabs/product-tabs.component.html b/src/app/features/website/product/engagement/components/product-tabs/product-tabs.component.html new file mode 100644 index 0000000..d99306a --- /dev/null +++ b/src/app/features/website/product/engagement/components/product-tabs/product-tabs.component.html @@ -0,0 +1,17 @@ +@if (tabs.length > 0) { + +} diff --git a/src/app/features/website/product/engagement/components/product-tabs/product-tabs.component.scss b/src/app/features/website/product/engagement/components/product-tabs/product-tabs.component.scss new file mode 100644 index 0000000..3cf39f2 --- /dev/null +++ b/src/app/features/website/product/engagement/components/product-tabs/product-tabs.component.scss @@ -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; +} diff --git a/src/app/features/website/product/engagement/components/product-tabs/product-tabs.component.ts b/src/app/features/website/product/engagement/components/product-tabs/product-tabs.component.ts new file mode 100644 index 0000000..bb8ec62 --- /dev/null +++ b/src/app/features/website/product/engagement/components/product-tabs/product-tabs.component.ts @@ -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(); + + selectTab(tab: ProductTabItem): void { + if (tab.enabled === false) { + return; + } + + this.activeKeyChange.emit(tab.key); + } +} diff --git a/src/app/features/website/product/engagement/components/question-card/question-card.component.html b/src/app/features/website/product/engagement/components/question-card/question-card.component.html new file mode 100644 index 0000000..0abcbe3 --- /dev/null +++ b/src/app/features/website/product/engagement/components/question-card/question-card.component.html @@ -0,0 +1,30 @@ +
+
+ {{ question.author }} + +
+ +

{{ question.text }}

+ + @if (question.answers.length > 0) { +
+ @for (answer of question.answers; track answer.id) { +
+
+ {{ answer.author }} + Official seller + @if (answer.isAccepted) { + Accepted answer + } +
+

{{ answer.text }}

+
+ } +
+ } + +
+ + +
+
diff --git a/src/app/features/website/product/engagement/components/question-card/question-card.component.scss b/src/app/features/website/product/engagement/components/question-card/question-card.component.scss new file mode 100644 index 0000000..2b4ee28 --- /dev/null +++ b/src/app/features/website/product/engagement/components/question-card/question-card.component.scss @@ -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; +} diff --git a/src/app/features/website/product/engagement/components/question-card/question-card.component.ts b/src/app/features/website/product/engagement/components/question-card/question-card.component.ts new file mode 100644 index 0000000..567fff5 --- /dev/null +++ b/src/app/features/website/product/engagement/components/question-card/question-card.component.ts @@ -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; +} diff --git a/src/app/features/website/product/engagement/components/question-form/question-form.component.html b/src/app/features/website/product/engagement/components/question-form/question-form.component.html new file mode 100644 index 0000000..3db7d3a --- /dev/null +++ b/src/app/features/website/product/engagement/components/question-form/question-form.component.html @@ -0,0 +1,19 @@ +
+

Ask a question

+ + + + + + +
diff --git a/src/app/features/website/product/engagement/components/question-form/question-form.component.scss b/src/app/features/website/product/engagement/components/question-form/question-form.component.scss new file mode 100644 index 0000000..1a1042d --- /dev/null +++ b/src/app/features/website/product/engagement/components/question-form/question-form.component.scss @@ -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; +} diff --git a/src/app/features/website/product/engagement/components/question-form/question-form.component.ts b/src/app/features/website/product/engagement/components/question-form/question-form.component.ts new file mode 100644 index 0000000..e837e20 --- /dev/null +++ b/src/app/features/website/product/engagement/components/question-form/question-form.component.ts @@ -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(); + + 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; + } +} diff --git a/src/app/features/website/product/engagement/components/question-list/question-list.component.html b/src/app/features/website/product/engagement/components/question-list/question-list.component.html new file mode 100644 index 0000000..2e3deeb --- /dev/null +++ b/src/app/features/website/product/engagement/components/question-list/question-list.component.html @@ -0,0 +1,32 @@ +
+ + + @if (askFormVisible()) { + + } + + @if (loading) { +
+
+
+
+ } @else if (result?.items?.length) { +
+ @for (question of result!.items; track question.id) { + + } +
+ } @else { +

No questions yet.

+ } + + @if (result && totalPages > 1) { +
+ + Page {{ result.page }} / {{ totalPages }} + +
+ } +
diff --git a/src/app/features/website/product/engagement/components/question-list/question-list.component.scss b/src/app/features/website/product/engagement/components/question-list/question-list.component.scss new file mode 100644 index 0000000..b208fb5 --- /dev/null +++ b/src/app/features/website/product/engagement/components/question-list/question-list.component.scss @@ -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; } +} diff --git a/src/app/features/website/product/engagement/components/question-list/question-list.component.ts b/src/app/features/website/product/engagement/components/question-list/question-list.component.ts new file mode 100644 index 0000000..edf52aa --- /dev/null +++ b/src/app/features/website/product/engagement/components/question-list/question-list.component.ts @@ -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 | null = null; + @Input() loading = false; + @Input() submitting = false; + + @Output() pageChange = new EventEmitter(); + @Output() submitQuestion = new EventEmitter(); + + 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); + } +} diff --git a/src/app/features/website/product/engagement/components/rating-summary/rating-summary.component.html b/src/app/features/website/product/engagement/components/rating-summary/rating-summary.component.html new file mode 100644 index 0000000..68ad8e7 --- /dev/null +++ b/src/app/features/website/product/engagement/components/rating-summary/rating-summary.component.html @@ -0,0 +1,26 @@ +
+ @if (loading) { +
+
+
+
+
+
+ } @else if (summary) { +
+ {{ summary.average | number:'1.1-1' }} + + {{ summary.totalReviews }} reviews +
+ +
+ @for (entry of summary.distribution; track entry.stars) { +
+ {{ entry.stars }}★ + + {{ entry.count }} ({{ entry.share | percent:'1.0-0' }}) +
+ } +
+ } +
diff --git a/src/app/features/website/product/engagement/components/rating-summary/rating-summary.component.scss b/src/app/features/website/product/engagement/components/rating-summary/rating-summary.component.scss new file mode 100644 index 0000000..9ee4b2e --- /dev/null +++ b/src/app/features/website/product/engagement/components/rating-summary/rating-summary.component.scss @@ -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; } +} diff --git a/src/app/features/website/product/engagement/components/rating-summary/rating-summary.component.ts b/src/app/features/website/product/engagement/components/rating-summary/rating-summary.component.ts new file mode 100644 index 0000000..536871f --- /dev/null +++ b/src/app/features/website/product/engagement/components/rating-summary/rating-summary.component.ts @@ -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; +} diff --git a/src/app/features/website/product/engagement/components/review-card/review-card.component.html b/src/app/features/website/product/engagement/components/review-card/review-card.component.html new file mode 100644 index 0000000..5be05e0 --- /dev/null +++ b/src/app/features/website/product/engagement/components/review-card/review-card.component.html @@ -0,0 +1,19 @@ +
+
+ + {{ review.author }} + Verified purchase + +
+ + @if (review.title) { +

{{ review.title }}

+ } + +

{{ review.text }}

+ +
+ + +
+
diff --git a/src/app/features/website/product/engagement/components/review-card/review-card.component.scss b/src/app/features/website/product/engagement/components/review-card/review-card.component.scss new file mode 100644 index 0000000..97f2cd9 --- /dev/null +++ b/src/app/features/website/product/engagement/components/review-card/review-card.component.scss @@ -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; +} diff --git a/src/app/features/website/product/engagement/components/review-card/review-card.component.ts b/src/app/features/website/product/engagement/components/review-card/review-card.component.ts new file mode 100644 index 0000000..1888fd9 --- /dev/null +++ b/src/app/features/website/product/engagement/components/review-card/review-card.component.ts @@ -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; +} diff --git a/src/app/features/website/product/engagement/components/review-form/review-form.component.html b/src/app/features/website/product/engagement/components/review-form/review-form.component.html new file mode 100644 index 0000000..7b9d765 --- /dev/null +++ b/src/app/features/website/product/engagement/components/review-form/review-form.component.html @@ -0,0 +1,23 @@ +
+

Leave a review

+ + + + + + + + + + + + +

Photo upload is planned for a future sprint.

+ + + diff --git a/src/app/features/website/product/engagement/components/review-form/review-form.component.scss b/src/app/features/website/product/engagement/components/review-form/review-form.component.scss new file mode 100644 index 0000000..d63410f --- /dev/null +++ b/src/app/features/website/product/engagement/components/review-form/review-form.component.scss @@ -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; +} diff --git a/src/app/features/website/product/engagement/components/review-form/review-form.component.ts b/src/app/features/website/product/engagement/components/review-form/review-form.component.ts new file mode 100644 index 0000000..a0368ff --- /dev/null +++ b/src/app/features/website/product/engagement/components/review-form/review-form.component.ts @@ -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(); + + 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; + } +} diff --git a/src/app/features/website/product/engagement/components/review-list/review-list.component.html b/src/app/features/website/product/engagement/components/review-list/review-list.component.html new file mode 100644 index 0000000..9c2c271 --- /dev/null +++ b/src/app/features/website/product/engagement/components/review-list/review-list.component.html @@ -0,0 +1,30 @@ +
+ @if (showSummary) { + + } + + + + @if (loading) { +
+
+
+
+ } @else if (result?.items?.length) { +
+ @for (review of result!.items; track review.id) { + + } +
+ } @else { +

No reviews yet.

+ } + + @if (result && totalPages > 1) { +
+ + Page {{ result.page }} / {{ totalPages }} + +
+ } +
diff --git a/src/app/features/website/product/engagement/components/review-list/review-list.component.scss b/src/app/features/website/product/engagement/components/review-list/review-list.component.scss new file mode 100644 index 0000000..6c2a32a --- /dev/null +++ b/src/app/features/website/product/engagement/components/review-list/review-list.component.scss @@ -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; } +} diff --git a/src/app/features/website/product/engagement/components/review-list/review-list.component.ts b/src/app/features/website/product/engagement/components/review-list/review-list.component.ts new file mode 100644 index 0000000..16e2b6d --- /dev/null +++ b/src/app/features/website/product/engagement/components/review-list/review-list.component.ts @@ -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 | null = null; + @Input() ratingSummary: RatingSummary | null = null; + @Input() loading = false; + @Input() submitting = false; + @Input() showSummary = true; + + @Output() pageChange = new EventEmitter(); + @Output() submitReview = new EventEmitter(); + + 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); + } +} diff --git a/src/app/features/website/product/engagement/components/star-selector/star-selector.component.html b/src/app/features/website/product/engagement/components/star-selector/star-selector.component.html new file mode 100644 index 0000000..afe7c8f --- /dev/null +++ b/src/app/features/website/product/engagement/components/star-selector/star-selector.component.html @@ -0,0 +1,13 @@ +
+ @for (star of stars; track star) { + + } +
diff --git a/src/app/features/website/product/engagement/components/star-selector/star-selector.component.scss b/src/app/features/website/product/engagement/components/star-selector/star-selector.component.scss new file mode 100644 index 0000000..d093f2f --- /dev/null +++ b/src/app/features/website/product/engagement/components/star-selector/star-selector.component.scss @@ -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); +} diff --git a/src/app/features/website/product/engagement/components/star-selector/star-selector.component.ts b/src/app/features/website/product/engagement/components/star-selector/star-selector.component.ts new file mode 100644 index 0000000..2b1a38a --- /dev/null +++ b/src/app/features/website/product/engagement/components/star-selector/star-selector.component.ts @@ -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(); + + readonly stars = [1, 2, 3, 4, 5]; +} diff --git a/src/app/features/website/product/engagement/components/stars/stars.component.html b/src/app/features/website/product/engagement/components/stars/stars.component.html new file mode 100644 index 0000000..a4ac883 --- /dev/null +++ b/src/app/features/website/product/engagement/components/stars/stars.component.html @@ -0,0 +1,5 @@ + + @for (star of stars; track star) { + + } + diff --git a/src/app/features/website/product/engagement/components/stars/stars.component.scss b/src/app/features/website/product/engagement/components/stars/stars.component.scss new file mode 100644 index 0000000..9cd85d3 --- /dev/null +++ b/src/app/features/website/product/engagement/components/stars/stars.component.scss @@ -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; +} diff --git a/src/app/features/website/product/engagement/components/stars/stars.component.ts b/src/app/features/website/product/engagement/components/stars/stars.component.ts new file mode 100644 index 0000000..caf387b --- /dev/null +++ b/src/app/features/website/product/engagement/components/stars/stars.component.ts @@ -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); + } +} diff --git a/src/app/shared/models/config/bootstrap-config.model.ts b/src/app/shared/models/config/bootstrap-config.model.ts index 4fdf644..7474585 100644 --- a/src/app/shared/models/config/bootstrap-config.model.ts +++ b/src/app/shared/models/config/bootstrap-config.model.ts @@ -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; diff --git a/src/app/shared/models/config/index.ts b/src/app/shared/models/config/index.ts index 4c0c22f..79d1079 100644 --- a/src/app/shared/models/config/index.ts +++ b/src/app/shared/models/config/index.ts @@ -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'; diff --git a/src/app/shared/models/config/product-page-config.model.ts b/src/app/shared/models/config/product-page-config.model.ts new file mode 100644 index 0000000..8410e17 --- /dev/null +++ b/src/app/shared/models/config/product-page-config.model.ts @@ -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 = { + 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 + } +}; diff --git a/src/app/widgets/ui/categories-widget.component.ts b/src/app/widgets/ui/categories-widget.component.ts index 344e5f6..4b04085 100644 --- a/src/app/widgets/ui/categories-widget.component.ts +++ b/src/app/widgets/ui/categories-widget.component.ts @@ -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(); + @Output() categorySelected = new EventEmitter(); - onCategorySelected(category: Category): void { + onCategorySelected(category: unknown): void { this.categorySelected.emit(category); - this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`, category.id]); } } \ No newline at end of file diff --git a/src/assets/mock/bootstrap/bootstrap.json b/src/assets/mock/bootstrap/bootstrap.json index d1ca77a..ef6dc2e 100644 --- a/src/assets/mock/bootstrap/bootstrap.json +++ b/src/assets/mock/bootstrap/bootstrap.json @@ -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" },