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

View File

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

View File

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