feat(product): implement sprint 9 product engagement module
This commit is contained in:
72
src/app/core/products/models/product-engagement.model.ts
Normal file
72
src/app/core/products/models/product-engagement.model.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type RatingStars = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
export interface RatingDistributionEntry {
|
||||
stars: RatingStars;
|
||||
count: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface RatingSummary {
|
||||
average: number;
|
||||
totalReviews: number;
|
||||
distribution: RatingDistributionEntry[];
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: string;
|
||||
rating: number;
|
||||
title: string;
|
||||
text: string;
|
||||
author: string;
|
||||
anonymous: boolean;
|
||||
verifiedPurchase: boolean;
|
||||
createdAt: string;
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
photos: string[];
|
||||
}
|
||||
|
||||
export interface Answer {
|
||||
id: string;
|
||||
text: string;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
isOfficialSeller: boolean;
|
||||
isAccepted: boolean;
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
text: string;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
answers: Answer[];
|
||||
}
|
||||
|
||||
export interface EngagementListQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface EngagementListResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface SubmitReviewInput {
|
||||
rating: number;
|
||||
title: string;
|
||||
text: string;
|
||||
anonymous: boolean;
|
||||
}
|
||||
|
||||
export interface SubmitQuestionInput {
|
||||
text: string;
|
||||
anonymous: boolean;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { PRODUCT_DATA_PROVIDER } from './product-data-provider.token';
|
||||
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from './models/product-domain.model';
|
||||
import { EngagementListQuery, EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from './models/product-engagement.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProductDataService {
|
||||
@@ -38,4 +39,24 @@ export class ProductDataService {
|
||||
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult> {
|
||||
return this.provider.getRelatedProducts(query);
|
||||
}
|
||||
|
||||
loadRating(productID: number): Observable<RatingSummary> {
|
||||
return this.provider.loadRating(productID);
|
||||
}
|
||||
|
||||
loadReviews(productID: number, query?: EngagementListQuery): Observable<EngagementListResult<Review>> {
|
||||
return this.provider.loadReviews(productID, query);
|
||||
}
|
||||
|
||||
loadQuestions(productID: number, query?: EngagementListQuery): Observable<EngagementListResult<Question>> {
|
||||
return this.provider.loadQuestions(productID, query);
|
||||
}
|
||||
|
||||
submitReview(productID: number, input: SubmitReviewInput): Observable<void> {
|
||||
return this.provider.submitReview(productID, input);
|
||||
}
|
||||
|
||||
submitQuestion(productID: number, input: SubmitQuestionInput): Observable<void> {
|
||||
return this.provider.submitQuestion(productID, input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { ApiService } from '../../../services';
|
||||
import { AuthService } from '../../../services/auth.service';
|
||||
import { CategoryService } from '../../categories/category.service';
|
||||
import { ProductDataProvider } from './product-data-provider.interface';
|
||||
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';
|
||||
import { Answer, EngagementListQuery, EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../models/product-engagement.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiProductDataProvider implements ProductDataProvider {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly categoryService: CategoryService
|
||||
private readonly categoryService: CategoryService,
|
||||
private readonly authService: AuthService
|
||||
) {}
|
||||
|
||||
getProducts(query: ProductListQuery = {}): Observable<ProductListResult> {
|
||||
@@ -58,6 +61,43 @@ export class ApiProductDataProvider implements ProductDataProvider {
|
||||
);
|
||||
}
|
||||
|
||||
loadRating(productID: number): Observable<RatingSummary> {
|
||||
return this.apiService.getItem(productID).pipe(
|
||||
map(item => this.toRatingSummary(this.toReviews(item)))
|
||||
);
|
||||
}
|
||||
|
||||
loadReviews(productID: number, query: EngagementListQuery = {}): Observable<EngagementListResult<Review>> {
|
||||
return this.apiService.getItem(productID).pipe(
|
||||
map(item => this.paginate(this.toReviews(item), query))
|
||||
);
|
||||
}
|
||||
|
||||
loadQuestions(productID: number, query: EngagementListQuery = {}): Observable<EngagementListResult<Question>> {
|
||||
return this.apiService.getItem(productID).pipe(
|
||||
map(item => this.paginate(this.toQuestions(item), query))
|
||||
);
|
||||
}
|
||||
|
||||
submitReview(productID: number, input: SubmitReviewInput): Observable<void> {
|
||||
return this.apiService.submitReview({
|
||||
itemID: productID,
|
||||
rating: input.rating,
|
||||
comment: input.title.trim() ? `${input.title.trim()}\n\n${input.text.trim()}` : input.text.trim(),
|
||||
sessionID: this.authService.session()?.sessionId || 'anonymous',
|
||||
timestamp: new Date().toISOString()
|
||||
}).pipe(map(() => void 0));
|
||||
}
|
||||
|
||||
submitQuestion(productID: number, input: SubmitQuestionInput): Observable<void> {
|
||||
return this.apiService.submitQuestion({
|
||||
itemID: productID,
|
||||
question: input.text.trim(),
|
||||
sessionID: this.authService.session()?.sessionId || 'anonymous',
|
||||
timestamp: new Date().toISOString()
|
||||
}).pipe(map(() => void 0));
|
||||
}
|
||||
|
||||
private toSearchOptions(query: ProductListQuery) {
|
||||
return {
|
||||
categoryIDs: query.categoryIDs,
|
||||
@@ -76,4 +116,112 @@ export class ApiProductDataProvider implements ProductDataProvider {
|
||||
skip: query.skip ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
private toReviews(product: Product): Review[] {
|
||||
const comments = product.comments ?? [];
|
||||
const callbacks = product.callbacks ?? [];
|
||||
|
||||
const merged = comments.length > 0
|
||||
? comments.map((comment, index) => ({
|
||||
id: comment.id ?? `comment-${product.itemID}-${index}`,
|
||||
rating: comment.stars ?? 0,
|
||||
content: comment.text ?? '',
|
||||
author: comment.author ?? 'Anonymous',
|
||||
createdAt: comment.createdAt ?? new Date().toISOString()
|
||||
}))
|
||||
: callbacks.map((callback, index) => ({
|
||||
id: `callback-${product.itemID}-${index}`,
|
||||
rating: callback.rating ?? 0,
|
||||
content: callback.content ?? '',
|
||||
author: callback.userID ?? 'Anonymous',
|
||||
createdAt: callback.timestamp ?? new Date().toISOString()
|
||||
}));
|
||||
|
||||
return merged
|
||||
.filter(review => review.content.trim().length > 0)
|
||||
.map(review => {
|
||||
const parts = review.content.split('\n\n');
|
||||
const maybeTitle = parts.length > 1 ? parts[0].trim() : '';
|
||||
const body = parts.length > 1 ? parts.slice(1).join('\n\n').trim() : review.content.trim();
|
||||
|
||||
return {
|
||||
id: review.id,
|
||||
rating: Math.max(1, Math.min(5, review.rating || 0)),
|
||||
title: maybeTitle,
|
||||
text: body,
|
||||
author: review.author || 'Anonymous',
|
||||
anonymous: review.author === 'Anonymous',
|
||||
verifiedPurchase: false,
|
||||
createdAt: review.createdAt,
|
||||
likes: 0,
|
||||
dislikes: 0,
|
||||
photos: []
|
||||
} as Review;
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
private toQuestions(product: Product): Question[] {
|
||||
return (product.questions ?? []).map((question, index) => {
|
||||
const answer: Answer | null = question.answer
|
||||
? {
|
||||
id: `answer-${product.itemID}-${index}`,
|
||||
text: question.answer,
|
||||
author: 'Seller',
|
||||
createdAt: new Date().toISOString(),
|
||||
isOfficialSeller: true,
|
||||
isAccepted: true,
|
||||
likes: 0,
|
||||
dislikes: 0
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: `question-${product.itemID}-${index}`,
|
||||
text: question.question,
|
||||
author: 'Customer',
|
||||
createdAt: new Date().toISOString(),
|
||||
likes: question.like ?? question.upvotes ?? 0,
|
||||
dislikes: question.dislike ?? question.downvotes ?? 0,
|
||||
answers: answer ? [answer] : []
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private toRatingSummary(reviews: Review[]): RatingSummary {
|
||||
const baseCounts: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
||||
|
||||
for (const review of reviews) {
|
||||
const stars = Math.max(1, Math.min(5, Math.round(review.rating)));
|
||||
baseCounts[stars] += 1;
|
||||
}
|
||||
|
||||
const totalReviews = reviews.length;
|
||||
const average = totalReviews > 0
|
||||
? Number((reviews.reduce((acc, review) => acc + review.rating, 0) / totalReviews).toFixed(1))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
average,
|
||||
totalReviews,
|
||||
distribution: [5, 4, 3, 2, 1].map(stars => ({
|
||||
stars: stars as 1 | 2 | 3 | 4 | 5,
|
||||
count: baseCounts[stars],
|
||||
share: totalReviews > 0 ? baseCounts[stars] / totalReviews : 0
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
private paginate<T>(items: T[], query: EngagementListQuery): EngagementListResult<T> {
|
||||
const pageSize = Math.max(1, query.pageSize ?? 5);
|
||||
const page = Math.max(1, query.page ?? 1);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
return {
|
||||
items: items.slice(skip, skip + pageSize),
|
||||
total: items.length,
|
||||
page,
|
||||
pageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';
|
||||
import { EngagementListQuery, EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../models/product-engagement.model';
|
||||
|
||||
export interface ProductDataProvider {
|
||||
getProducts(query?: ProductListQuery): Observable<ProductListResult>;
|
||||
@@ -10,4 +11,9 @@ export interface ProductDataProvider {
|
||||
getLatestProducts(query?: ProductListQuery): Observable<ProductListResult>;
|
||||
getProductsByCategory(categoryID: number, query?: ProductListQuery): Observable<ProductListResult>;
|
||||
getRelatedProducts(query: RelatedProductsQuery): Observable<ProductListResult>;
|
||||
loadRating(productID: number): Observable<RatingSummary>;
|
||||
loadReviews(productID: number, query?: EngagementListQuery): Observable<EngagementListResult<Review>>;
|
||||
loadQuestions(productID: number, query?: EngagementListQuery): Observable<EngagementListResult<Question>>;
|
||||
submitReview(productID: number, input: SubmitReviewInput): Observable<void>;
|
||||
submitQuestion(productID: number, input: SubmitQuestionInput): Observable<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user