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

@@ -3,18 +3,6 @@
@if (simpleDescription) {
<p>{{ simpleDescription }}</p>
}
@if (descriptionFields.length > 0) {
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
<dl>
@for (field of descriptionFields; track field.key) {
<div>
<dt>{{ field.key }}</dt>
<dd>{{ field.value }}</dd>
</div>
}
</dl>
} @else if (product.description) {
<p>{{ product.description }}</p>
}

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
<section class="product-specifications card section">
<h3>Specifications</h3>
@if (descriptionFields.length > 0) {
<dl>
@for (field of descriptionFields; track field.key) {
<div>
<dt>{{ field.key }}</dt>
<dd>{{ field.value }}</dd>
</div>
}
</dl>
} @else {
<p>No specifications provided yet.</p>
}
</section>

View File

@@ -0,0 +1,42 @@
.product-specifications {
padding: 18px;
}
h3 {
margin: 0 0 12px;
color: var(--text-primary);
}
dl {
margin: 0;
display: grid;
gap: 8px;
}
dl div {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px;
padding: 8px 0;
border-bottom: 1px dashed var(--border-color);
}
dt {
color: var(--text-secondary);
}
dd {
margin: 0;
color: var(--text-primary);
}
p {
margin: 0;
color: var(--text-secondary);
}
@media (max-width: 680px) {
dl div {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,17 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Product } from '../../../../../core/products/models/product-domain.model';
@Component({
selector: 'app-product-specifications',
standalone: true,
templateUrl: './product-specifications.component.html',
styleUrls: ['./product-specifications.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductSpecificationsComponent {
@Input({ required: true }) product!: Product;
get descriptionFields() {
return this.product.descriptionFields ?? [];
}
}

View File

@@ -0,0 +1,8 @@
<section class="product-warranty card section">
<h3>Warranty and returns</h3>
<ul>
<li>Warranty terms are provided by the seller and local law.</li>
<li>Return eligibility depends on item condition and category.</li>
<li>Detailed return instructions are available after purchase.</li>
</ul>
</section>

View File

@@ -0,0 +1,16 @@
.product-warranty {
padding: 18px;
}
h3 {
margin: 0 0 12px;
color: var(--text-primary);
}
ul {
margin: 0;
padding-left: 20px;
display: grid;
gap: 8px;
color: var(--text-secondary);
}

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-product-warranty',
standalone: true,
templateUrl: './product-warranty.component.html',
styleUrls: ['./product-warranty.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductWarrantyComponent {}

View File

@@ -58,13 +58,100 @@
</div>
</section>
<app-product-description [product]="currentProduct" [simpleDescription]="productDescription(currentProduct)" />
@if (tabsEnabled()) {
<section class="product-engagement">
<app-product-tabs
[tabs]="tabs()"
[activeKey]="activeTab()"
(activeKeyChange)="activeTab.set($event)" />
<app-related-products
[products]="relatedProducts()"
(productSelected)="selectRelatedProduct($event)"
(addToCart)="addRelatedToCart($event)"
/>
<div class="product-tab-content">
@switch (activeTab()) {
@case ('description') {
<app-product-description [product]="currentProduct" [simpleDescription]="productDescription(currentProduct)" />
}
@case ('specifications') {
<app-product-specifications [product]="currentProduct" />
}
@case ('reviews') {
<app-review-list
[result]="reviewsResult()"
[ratingSummary]="ratingSummary()"
[loading]="reviewsLoading() || ratingLoading()"
[submitting]="reviewSubmitting()"
[showSummary]="productPageConfigState().reviews.showSummary !== false"
(pageChange)="loadReviews($event)"
(submitReview)="submitReview($event)" />
}
@case ('questions') {
<app-question-list
[result]="questionsResult()"
[loading]="questionsLoading()"
[submitting]="questionSubmitting()"
(pageChange)="loadQuestions($event)"
(submitQuestion)="submitQuestion($event)" />
}
@case ('delivery') {
<app-product-delivery-information [product]="currentProduct" [currency]="effectiveCurrency()" />
}
@case ('warranty') {
<app-product-warranty />
}
}
</div>
</section>
} @else {
<section class="product-engagement product-engagement-stacked">
@if (isSectionEnabled('description')) {
<app-product-description [product]="currentProduct" [simpleDescription]="productDescription(currentProduct)" />
}
@if (isSectionEnabled('specifications')) {
<app-product-specifications [product]="currentProduct" />
}
@if (isSectionEnabled('reviews')) {
<app-review-list
[result]="reviewsResult()"
[ratingSummary]="ratingSummary()"
[loading]="reviewsLoading() || ratingLoading()"
[submitting]="reviewSubmitting()"
[showSummary]="productPageConfigState().reviews.showSummary !== false"
(pageChange)="loadReviews($event)"
(submitReview)="submitReview($event)" />
}
@if (isSectionEnabled('questions')) {
<app-question-list
[result]="questionsResult()"
[loading]="questionsLoading()"
[submitting]="questionSubmitting()"
(pageChange)="loadQuestions($event)"
(submitQuestion)="submitQuestion($event)" />
}
@if (isSectionEnabled('delivery')) {
<app-product-delivery-information [product]="currentProduct" [currency]="effectiveCurrency()" />
}
@if (isSectionEnabled('warranty')) {
<app-product-warranty />
}
</section>
}
@if (productPageConfigState().relatedProducts.enabled !== false) {
<app-related-products
[products]="relatedProducts()"
(productSelected)="selectRelatedProduct($event)"
(addToCart)="addRelatedToCart($event)"
/>
}
}
}
</main>

View File

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

View File

@@ -2,18 +2,26 @@ import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signa
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Product } from '../../../../core/products/models/product-domain.model';
import { EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../../../core/products/models/product-engagement.model';
import { ConfigService } from '../../../../core/config/config.service';
import { ProductFacade } from '../../../../facades/platform/product.facade';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { CartService } from '../../../../services';
import { LanguageService } from '../../../../services/language.service';
import { DEFAULT_PRODUCT_PAGE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
import { ProductInformationComponent } from '../components/product-information/product-information.component';
import { ProductDescriptionComponent } from '../components/product-description/product-description.component';
import { ProductSpecificationsComponent } from '../components/product-specifications/product-specifications.component';
import { ProductWarrantyComponent } from '../components/product-warranty/product-warranty.component';
import { RelatedProductsComponent } from '../components/related-products/related-products.component';
import { ProductVariantSelectorComponent } from '../components/variant-selector/variant-selector.component';
import { ProductTabItem, ProductTabKey, ProductTabsComponent } from '../engagement/components/product-tabs/product-tabs.component';
import { QuestionListComponent } from '../engagement/components/question-list/question-list.component';
import { ReviewListComponent } from '../engagement/components/review-list/review-list.component';
@Component({
selector: 'app-product-details-container',
@@ -27,6 +35,11 @@ import { ProductVariantSelectorComponent } from '../components/variant-selector/
ProductVariantSelectorComponent,
ProductDeliveryInformationComponent,
ProductDescriptionComponent,
ProductSpecificationsComponent,
ProductWarrantyComponent,
ProductTabsComponent,
ReviewListComponent,
QuestionListComponent,
RelatedProductsComponent,
],
templateUrl: './product-details-container.component.html',
@@ -37,10 +50,13 @@ export class ProductDetailsContainerComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly configService = inject(ConfigService);
private readonly productFacade = inject(ProductFacade);
private readonly cartService = inject(CartService);
private readonly languageService = inject(LanguageService);
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
readonly product = signal<Product | null>(null);
readonly relatedProducts = signal<Product[]>([]);
readonly selectedPhotoIndex = signal(0);
@@ -50,6 +66,38 @@ export class ProductDetailsContainerComponent {
readonly error = signal<string | null>(null);
readonly missing = signal(false);
readonly activeTab = signal<ProductTabKey>('description');
readonly ratingSummary = signal<RatingSummary | null>(null);
readonly reviewsResult = signal<EngagementListResult<Review> | null>(null);
readonly questionsResult = signal<EngagementListResult<Question> | null>(null);
readonly ratingLoading = signal(false);
readonly reviewsLoading = signal(false);
readonly questionsLoading = signal(false);
readonly reviewSubmitting = signal(false);
readonly questionSubmitting = signal(false);
readonly reviewsPageSize = computed(() => this.productPageConfigState().reviews.pageSize ?? 5);
readonly questionsPageSize = computed(() => this.productPageConfigState().questions.pageSize ?? 5);
readonly tabsEnabled = computed(() => this.productPageConfigState().tabs.enabled !== false);
readonly tabs = computed<ProductTabItem[]>(() => {
const config = this.productPageConfigState();
const reviewCount = this.ratingSummary()?.totalReviews ?? this.reviewsResult()?.total ?? 0;
const questionCount = this.questionsResult()?.total ?? 0;
const defaultItems: ProductTabKey[] = ['description', 'specifications', 'reviews', 'questions', 'delivery', 'warranty'];
const enabledItems = (config.tabs.items?.length ? config.tabs.items : defaultItems)
.filter((key): key is ProductTabKey => this.isTabEnabled(key));
return enabledItems.map(key => ({
key,
label: this.getTabLabel(key),
count: key === 'reviews' ? reviewCount : key === 'questions' ? questionCount : undefined,
enabled: true
}));
});
readonly availableColours = computed(() => {
const details = this.product()?.itemDetails;
if (!details?.length) return [] as string[];
@@ -95,6 +143,8 @@ export class ProductDetailsContainerComponent {
});
constructor() {
this.ensureInitialActiveTab();
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(params => this.loadProduct(Number(params.get('id'))));
@@ -113,6 +163,9 @@ export class ProductDetailsContainerComponent {
this.product.set(null);
this.relatedProducts.set([]);
this.selectedPhotoIndex.set(0);
this.ratingSummary.set(null);
this.reviewsResult.set(null);
this.questionsResult.set(null);
this.productFacade.getProduct(productId)
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -127,6 +180,7 @@ export class ProductDetailsContainerComponent {
this.product.set(product);
this.initVariantSelection(product);
this.loadRelatedProducts(product);
this.loadEngagement(product.itemID);
this.loading.set(false);
},
error: () => {
@@ -182,6 +236,95 @@ export class ProductDetailsContainerComponent {
this.loadProduct(productId);
}
loadReviews(page: number): void {
const current = this.product();
if (!current || this.productPageConfigState().reviews.enabled === false) {
return;
}
this.reviewsLoading.set(true);
this.productFacade.loadReviews(current.itemID, {
page,
pageSize: this.reviewsPageSize()
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: result => {
this.reviewsResult.set(result);
this.reviewsLoading.set(false);
},
error: () => {
this.reviewsResult.set({ items: [], total: 0, page, pageSize: this.reviewsPageSize() });
this.reviewsLoading.set(false);
}
});
}
loadQuestions(page: number): void {
const current = this.product();
if (!current || this.productPageConfigState().questions.enabled === false) {
return;
}
this.questionsLoading.set(true);
this.productFacade.loadQuestions(current.itemID, {
page,
pageSize: this.questionsPageSize()
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: result => {
this.questionsResult.set(result);
this.questionsLoading.set(false);
},
error: () => {
this.questionsResult.set({ items: [], total: 0, page, pageSize: this.questionsPageSize() });
this.questionsLoading.set(false);
}
});
}
submitReview(input: SubmitReviewInput): void {
const current = this.product();
if (!current) {
return;
}
this.reviewSubmitting.set(true);
this.productFacade.submitReview(current.itemID, input)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.reviewSubmitting.set(false);
this.loadRating(current.itemID);
this.loadReviews(this.reviewsResult()?.page ?? 1);
},
error: () => this.reviewSubmitting.set(false)
});
}
submitQuestion(input: SubmitQuestionInput): void {
const current = this.product();
if (!current) {
return;
}
this.questionSubmitting.set(true);
this.productFacade.submitQuestion(current.itemID, input)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.questionSubmitting.set(false);
this.loadQuestions(1);
},
error: () => this.questionSubmitting.set(false)
});
}
isSectionEnabled(key: ProductTabKey): boolean {
return this.isTabEnabled(key);
}
private initVariantSelection(product: Product): void {
const details = product.itemDetails;
if (details?.length) {
@@ -195,6 +338,11 @@ export class ProductDetailsContainerComponent {
}
private loadRelatedProducts(product: Product): void {
if (this.productPageConfigState().relatedProducts.enabled === false) {
this.relatedProducts.set([]);
return;
}
this.productFacade.getRelatedProducts({
productID: product.itemID,
categoryID: product.categoryID,
@@ -205,4 +353,112 @@ export class ProductDetailsContainerComponent {
error: () => this.relatedProducts.set([]),
});
}
private loadEngagement(productID: number): void {
if (this.productPageConfigState().rating.enabled !== false) {
this.loadRating(productID);
}
if (this.productPageConfigState().reviews.enabled !== false) {
this.loadReviews(1);
}
if (this.productPageConfigState().questions.enabled !== false) {
this.loadQuestions(1);
}
}
private loadRating(productID: number): void {
this.ratingLoading.set(true);
this.productFacade.loadRating(productID)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: summary => {
this.ratingSummary.set(summary);
this.ratingLoading.set(false);
},
error: () => {
this.ratingSummary.set({
average: 0,
totalReviews: 0,
distribution: [5, 4, 3, 2, 1].map(stars => ({ stars: stars as 1 | 2 | 3 | 4 | 5, count: 0, share: 0 }))
});
this.ratingLoading.set(false);
}
});
}
private ensureInitialActiveTab(): void {
const tabs = this.tabs();
if (tabs.length > 0) {
this.activeTab.set(tabs[0].key);
}
}
private isTabEnabled(key: ProductTabKey): boolean {
const config = this.productPageConfigState();
switch (key) {
case 'reviews':
return config.reviews.enabled !== false;
case 'questions':
return config.questions.enabled !== false;
case 'delivery':
return true;
case 'description':
case 'specifications':
case 'warranty':
default:
return true;
}
}
private getTabLabel(key: ProductTabKey): string {
switch (key) {
case 'description':
return 'Description';
case 'specifications':
return 'Specifications';
case 'reviews':
return 'Reviews';
case 'questions':
return 'Questions';
case 'delivery':
return 'Delivery';
case 'warranty':
return 'Warranty';
default:
return key;
}
}
private resolveProductPageConfig(): Required<ProductPageConfig> {
const bootstrapConfig = (this.configService.getBootstrapSnapshot() as any)?.productPage ?? {};
return {
rating: {
...DEFAULT_PRODUCT_PAGE_CONFIG.rating,
...(bootstrapConfig.rating ?? {})
},
reviews: {
...DEFAULT_PRODUCT_PAGE_CONFIG.reviews,
...(bootstrapConfig.reviews ?? {})
},
questions: {
...DEFAULT_PRODUCT_PAGE_CONFIG.questions,
...(bootstrapConfig.questions ?? {})
},
tabs: {
...DEFAULT_PRODUCT_PAGE_CONFIG.tabs,
...(bootstrapConfig.tabs ?? {}),
items: Array.isArray(bootstrapConfig.tabs?.items) && bootstrapConfig.tabs.items.length > 0
? bootstrapConfig.tabs.items
: DEFAULT_PRODUCT_PAGE_CONFIG.tabs.items
},
relatedProducts: {
...DEFAULT_PRODUCT_PAGE_CONFIG.relatedProducts,
...(bootstrapConfig.relatedProducts ?? {})
}
};
}
}

View File

@@ -0,0 +1,17 @@
@if (tabs.length > 0) {
<nav class="product-tabs" aria-label="Product tabs">
@for (tab of tabs; track tab.key) {
<button
type="button"
class="tab-btn"
[class.active]="activeKey === tab.key"
[disabled]="tab.enabled === false"
(click)="selectTab(tab)">
<span>{{ tab.label }}</span>
@if (tab.count != null) {
<small>{{ tab.count }}</small>
}
</button>
}
</nav>
}

View File

@@ -0,0 +1,42 @@
.product-tabs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.tab-btn {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-md);
min-height: 46px;
padding: 10px 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-primary);
font-weight: 700;
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.tab-btn small {
font-size: 0.75rem;
color: var(--text-secondary);
}
.tab-btn:hover:not(:disabled) {
transform: translateY(-1px);
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
.tab-btn.active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 10%, white);
}
.tab-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}

View File

@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
export type ProductTabKey = 'description' | 'specifications' | 'reviews' | 'questions' | 'delivery' | 'warranty';
export interface ProductTabItem {
key: ProductTabKey;
label: string;
count?: number;
enabled?: boolean;
}
@Component({
selector: 'app-product-tabs',
standalone: true,
templateUrl: './product-tabs.component.html',
styleUrls: ['./product-tabs.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductTabsComponent {
@Input() tabs: ProductTabItem[] = [];
@Input() activeKey: ProductTabKey | null = null;
@Output() activeKeyChange = new EventEmitter<ProductTabKey>();
selectTab(tab: ProductTabItem): void {
if (tab.enabled === false) {
return;
}
this.activeKeyChange.emit(tab.key);
}
}

View File

@@ -0,0 +1,30 @@
<article class="question-card card">
<header>
<strong>{{ question.author }}</strong>
<time>{{ question.createdAt | date:'mediumDate' }}</time>
</header>
<p class="question-text">{{ question.text }}</p>
@if (question.answers.length > 0) {
<div class="answers">
@for (answer of question.answers; track answer.id) {
<div class="answer">
<div class="answer-meta">
<strong>{{ answer.author }}</strong>
<span class="seller-pill" [class.visible]="answer.isOfficialSeller">Official seller</span>
@if (answer.isAccepted) {
<span class="accepted-pill">Accepted answer</span>
}
</div>
<p>{{ answer.text }}</p>
</div>
}
</div>
}
<footer>
<button type="button" disabled>Like ({{ question.likes }})</button>
<button type="button" disabled>Dislike ({{ question.dislikes }})</button>
</footer>
</article>

View File

@@ -0,0 +1,79 @@
.question-card {
padding: 16px;
display: grid;
gap: 12px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--text-secondary);
}
.question-text {
margin: 0;
color: var(--text-primary);
}
.answers {
display: grid;
gap: 8px;
}
.answer {
background: color-mix(in srgb, var(--primary-color) 6%, white);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 10px;
}
.answer p {
margin: 0;
color: var(--text-secondary);
}
.answer-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.seller-pill,
.accepted-pill {
border-radius: 999px;
padding: 2px 8px;
font-size: 0.75rem;
}
.seller-pill {
border: 1px dashed var(--border-dark);
color: var(--text-secondary);
}
.seller-pill.visible {
border-style: solid;
color: var(--primary-color);
}
.accepted-pill {
background: #e9f9ec;
color: #18723a;
}
footer {
display: flex;
gap: 8px;
}
footer button {
min-height: 32px;
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--bg-primary);
color: var(--text-secondary);
padding: 0 10px;
}

View File

@@ -0,0 +1,15 @@
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Question } from '../../../../../../core/products/models/product-engagement.model';
@Component({
selector: 'app-question-card',
standalone: true,
imports: [DatePipe],
templateUrl: './question-card.component.html',
styleUrls: ['./question-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QuestionCardComponent {
@Input({ required: true }) question!: Question;
}

View File

@@ -0,0 +1,19 @@
<form class="question-form card" (ngSubmit)="onSubmit()">
<h3>Ask a question</h3>
<textarea
id="question-text"
name="text"
rows="4"
[(ngModel)]="text"
placeholder="Ask about size, delivery, compatibility or usage"></textarea>
<label class="check">
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" />
<span>Ask anonymously</span>
</label>
<button type="submit" [disabled]="submitting || !text.trim()">
@if (submitting) { Submitting... } @else { Submit question }
</button>
</form>

View File

@@ -0,0 +1,40 @@
.question-form {
padding: 16px;
display: grid;
gap: 10px;
}
h3 {
margin: 0;
color: var(--text-primary);
}
textarea {
width: 100%;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 10px 12px;
font: inherit;
resize: vertical;
}
.check {
display: inline-flex;
align-items: center;
gap: 8px;
}
button {
justify-self: start;
min-height: 40px;
border: 0;
border-radius: var(--radius-sm);
background: var(--primary-color);
color: #fff;
padding: 0 16px;
font-weight: 700;
}
button:disabled {
opacity: 0.6;
}

View File

@@ -0,0 +1,34 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SubmitQuestionInput } from '../../../../../../core/products/models/product-engagement.model';
@Component({
selector: 'app-question-form',
standalone: true,
imports: [FormsModule],
templateUrl: './question-form.component.html',
styleUrls: ['./question-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QuestionFormComponent {
@Input() submitting = false;
@Output() submitQuestion = new EventEmitter<SubmitQuestionInput>();
text = '';
anonymous = false;
onSubmit(): void {
const payload: SubmitQuestionInput = {
text: this.text,
anonymous: this.anonymous
};
if (!payload.text.trim()) {
return;
}
this.submitQuestion.emit(payload);
this.text = '';
this.anonymous = false;
}
}

View File

@@ -0,0 +1,32 @@
<section class="question-list section">
<button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())">
@if (askFormVisible()) { Hide question form } @else { Ask question }
</button>
@if (askFormVisible()) {
<app-question-form [submitting]="submitting" (submitQuestion)="onSubmit($event)" />
}
@if (loading) {
<div class="skeleton-list">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
} @else if (result?.items?.length) {
<div class="question-items">
@for (question of result!.items; track question.id) {
<app-question-card [question]="question" />
}
</div>
} @else {
<p class="empty">No questions yet.</p>
}
@if (result && totalPages > 1) {
<div class="pager">
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">Previous</button>
<span>Page {{ result.page }} / {{ totalPages }}</span>
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">Next</button>
</div>
}
</section>

View File

@@ -0,0 +1,57 @@
.question-list {
display: grid;
gap: 14px;
}
.ask-btn {
justify-self: start;
min-height: 40px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
padding: 0 14px;
font-weight: 700;
}
.question-items {
display: grid;
gap: 12px;
}
.empty {
margin: 0;
color: var(--text-secondary);
}
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pager button {
min-height: 36px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
padding: 0 12px;
}
.skeleton-list {
display: grid;
gap: 12px;
}
.skeleton-card {
height: 110px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%);
background-size: 200% 100%;
animation: shimmer 1.2s linear infinite;
}
@keyframes shimmer {
to { background-position: -200% 0; }
}

View File

@@ -0,0 +1,50 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { EngagementListResult, Question, SubmitQuestionInput } from '../../../../../../core/products/models/product-engagement.model';
import { QuestionCardComponent } from '../question-card/question-card.component';
import { QuestionFormComponent } from '../question-form/question-form.component';
@Component({
selector: 'app-question-list',
standalone: true,
imports: [QuestionCardComponent, QuestionFormComponent],
templateUrl: './question-list.component.html',
styleUrls: ['./question-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QuestionListComponent {
@Input() result: EngagementListResult<Question> | null = null;
@Input() loading = false;
@Input() submitting = false;
@Output() pageChange = new EventEmitter<number>();
@Output() submitQuestion = new EventEmitter<SubmitQuestionInput>();
readonly askFormVisible = signal(false);
get totalPages(): number {
const total = this.result?.total ?? 0;
const pageSize = this.result?.pageSize ?? 1;
return Math.max(1, Math.ceil(total / pageSize));
}
previousPage(): void {
if (!this.result || this.result.page <= 1) {
return;
}
this.pageChange.emit(this.result.page - 1);
}
nextPage(): void {
if (!this.result || this.result.page >= this.totalPages) {
return;
}
this.pageChange.emit(this.result.page + 1);
}
onSubmit(payload: SubmitQuestionInput): void {
this.submitQuestion.emit(payload);
this.askFormVisible.set(false);
}
}

View File

@@ -0,0 +1,26 @@
<section class="rating-summary card section">
@if (loading) {
<div class="rating-skeleton">
<div class="line w40"></div>
<div class="line w60"></div>
<div class="line w100"></div>
<div class="line w100"></div>
</div>
} @else if (summary) {
<div class="headline">
<strong class="average">{{ summary.average | number:'1.1-1' }}</strong>
<app-stars [rating]="summary.average" size="lg" />
<span class="total">{{ summary.totalReviews }} reviews</span>
</div>
<div class="distribution">
@for (entry of summary.distribution; track entry.stars) {
<div class="distribution-row">
<span>{{ entry.stars }}★</span>
<progress [value]="entry.share" max="1"></progress>
<small>{{ entry.count }} ({{ entry.share | percent:'1.0-0' }})</small>
</div>
}
</div>
}
</section>

View File

@@ -0,0 +1,73 @@
.rating-summary {
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.headline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.average {
font-size: 1.75rem;
color: var(--text-primary);
}
.total {
color: var(--text-secondary);
font-weight: 600;
}
.distribution {
display: flex;
flex-direction: column;
gap: 8px;
}
.distribution-row {
display: grid;
grid-template-columns: 44px 1fr auto;
gap: 10px;
align-items: center;
font-size: 0.88rem;
}
.distribution-row progress {
width: 100%;
height: 8px;
border-radius: 999px;
overflow: hidden;
}
.distribution-row progress::-webkit-progress-bar {
background: var(--bg-tertiary);
}
.distribution-row progress::-webkit-progress-value {
background: var(--primary-color);
}
.rating-skeleton {
display: grid;
gap: 8px;
}
.line {
height: 12px;
border-radius: 999px;
background: linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%);
background-size: 200% 100%;
animation: shimmer 1.2s linear infinite;
}
.w40 { width: 40%; }
.w60 { width: 60%; }
.w100 { width: 100%; }
@keyframes shimmer {
to { background-position: -200% 0; }
}

View File

@@ -0,0 +1,17 @@
import { DecimalPipe, PercentPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { RatingSummary } from '../../../../../../core/products/models/product-engagement.model';
import { StarsComponent } from '../stars/stars.component';
@Component({
selector: 'app-rating-summary',
standalone: true,
imports: [DecimalPipe, PercentPipe, StarsComponent],
templateUrl: './rating-summary.component.html',
styleUrls: ['./rating-summary.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RatingSummaryComponent {
@Input() summary: RatingSummary | null = null;
@Input() loading = false;
}

View File

@@ -0,0 +1,19 @@
<article class="review-card card">
<header class="review-header">
<app-stars [rating]="review.rating" size="sm" />
<strong class="author">{{ review.author }}</strong>
<span class="pill">Verified purchase</span>
<time>{{ review.createdAt | date:'mediumDate' }}</time>
</header>
@if (review.title) {
<h4>{{ review.title }}</h4>
}
<p>{{ review.text }}</p>
<footer class="review-actions">
<button type="button" disabled>Like ({{ review.likes }})</button>
<button type="button" disabled>Dislike ({{ review.dislikes }})</button>
</footer>
</article>

View File

@@ -0,0 +1,52 @@
.review-card {
padding: 16px;
display: grid;
gap: 10px;
}
.review-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.author {
color: var(--text-primary);
}
.pill {
border: 1px dashed var(--border-dark);
border-radius: 999px;
padding: 3px 8px;
font-size: 0.75rem;
color: var(--text-secondary);
}
h4 {
margin: 0;
font-size: 1rem;
color: var(--text-primary);
}
p {
margin: 0;
color: var(--text-secondary);
line-height: 1.5;
}
.review-actions {
display: flex;
gap: 8px;
}
.review-actions button {
min-height: 32px;
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--bg-primary);
color: var(--text-secondary);
padding: 0 10px;
}

View File

@@ -0,0 +1,16 @@
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Review } from '../../../../../../core/products/models/product-engagement.model';
import { StarsComponent } from '../stars/stars.component';
@Component({
selector: 'app-review-card',
standalone: true,
imports: [DatePipe, StarsComponent],
templateUrl: './review-card.component.html',
styleUrls: ['./review-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReviewCardComponent {
@Input({ required: true }) review!: Review;
}

View File

@@ -0,0 +1,23 @@
<form class="review-form card" (ngSubmit)="onSubmit()">
<h3>Leave a review</h3>
<label>Rating</label>
<app-star-selector [(rating)]="rating" />
<label for="review-title">Title</label>
<input id="review-title" name="title" [(ngModel)]="title" placeholder="Short headline" maxlength="120" />
<label for="review-text">Review</label>
<textarea id="review-text" name="text" [(ngModel)]="text" rows="4" placeholder="Describe your experience"></textarea>
<label class="check">
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" />
<span>Submit anonymously</span>
</label>
<p class="upload-placeholder">Photo upload is planned for a future sprint.</p>
<button type="submit" [disabled]="submitting || !rating || !text.trim()">
@if (submitting) { Submitting... } @else { Submit review }
</button>
</form>

View File

@@ -0,0 +1,59 @@
.review-form {
padding: 16px;
display: grid;
gap: 8px;
}
h3 {
margin: 0 0 6px;
color: var(--text-primary);
}
label {
font-weight: 700;
color: var(--text-primary);
font-size: 0.9rem;
}
input,
textarea {
width: 100%;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 10px 12px;
font: inherit;
}
textarea {
resize: vertical;
}
.check {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.upload-placeholder {
margin: 0;
color: var(--text-light);
font-size: 0.85rem;
}
button {
justify-self: start;
min-height: 40px;
border: 0;
border-radius: var(--radius-sm);
background: var(--primary-color);
color: #fff;
padding: 0 16px;
font-weight: 700;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View File

@@ -0,0 +1,45 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SubmitReviewInput } from '../../../../../../core/products/models/product-engagement.model';
import { StarSelectorComponent } from '../star-selector/star-selector.component';
@Component({
selector: 'app-review-form',
standalone: true,
imports: [FormsModule, StarSelectorComponent],
templateUrl: './review-form.component.html',
styleUrls: ['./review-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReviewFormComponent {
@Input() submitting = false;
@Output() submitReview = new EventEmitter<SubmitReviewInput>();
rating = 0;
title = '';
text = '';
anonymous = false;
onSubmit(): void {
const payload: SubmitReviewInput = {
rating: this.rating,
title: this.title,
text: this.text,
anonymous: this.anonymous
};
if (!payload.rating || !payload.text.trim()) {
return;
}
this.submitReview.emit(payload);
this.reset();
}
private reset(): void {
this.rating = 0;
this.title = '';
this.text = '';
this.anonymous = false;
}
}

View File

@@ -0,0 +1,30 @@
<section class="review-list section">
@if (showSummary) {
<app-rating-summary [summary]="ratingSummary" [loading]="loading" />
}
<app-review-form [submitting]="submitting" (submitReview)="submitReview.emit($event)" />
@if (loading) {
<div class="skeleton-list">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
} @else if (result?.items?.length) {
<div class="review-items">
@for (review of result!.items; track review.id) {
<app-review-card [review]="review" />
}
</div>
} @else {
<p class="empty">No reviews yet.</p>
}
@if (result && totalPages > 1) {
<div class="pager">
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">Previous</button>
<span>Page {{ result.page }} / {{ totalPages }}</span>
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">Next</button>
</div>
}
</section>

View File

@@ -0,0 +1,47 @@
.review-list {
display: grid;
gap: 16px;
}
.review-items {
display: grid;
gap: 12px;
}
.empty {
margin: 0;
color: var(--text-secondary);
}
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pager button {
min-height: 36px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
padding: 0 12px;
cursor: pointer;
}
.skeleton-list {
display: grid;
gap: 12px;
}
.skeleton-card {
height: 128px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, #edf1f1 25%, #e2ebea 50%, #edf1f1 75%);
background-size: 200% 100%;
animation: shimmer 1.2s linear infinite;
}
@keyframes shimmer {
to { background-position: -200% 0; }
}

View File

@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { EngagementListResult, Review, SubmitReviewInput } from '../../../../../../core/products/models/product-engagement.model';
import { RatingSummary } from '../../../../../../core/products/models/product-engagement.model';
import { RatingSummaryComponent } from '../rating-summary/rating-summary.component';
import { ReviewCardComponent } from '../review-card/review-card.component';
import { ReviewFormComponent } from '../review-form/review-form.component';
@Component({
selector: 'app-review-list',
standalone: true,
imports: [RatingSummaryComponent, ReviewCardComponent, ReviewFormComponent],
templateUrl: './review-list.component.html',
styleUrls: ['./review-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReviewListComponent {
@Input() result: EngagementListResult<Review> | null = null;
@Input() ratingSummary: RatingSummary | null = null;
@Input() loading = false;
@Input() submitting = false;
@Input() showSummary = true;
@Output() pageChange = new EventEmitter<number>();
@Output() submitReview = new EventEmitter<SubmitReviewInput>();
get totalPages(): number {
const total = this.result?.total ?? 0;
const pageSize = this.result?.pageSize ?? 1;
return Math.max(1, Math.ceil(total / pageSize));
}
previousPage(): void {
if (!this.result || this.result.page <= 1) {
return;
}
this.pageChange.emit(this.result.page - 1);
}
nextPage(): void {
if (!this.result || this.result.page >= this.totalPages) {
return;
}
this.pageChange.emit(this.result.page + 1);
}
}

View File

@@ -0,0 +1,13 @@
<div class="star-selector" role="radiogroup" aria-label="Select rating">
@for (star of stars; track star) {
<button
type="button"
class="star-btn"
[class.selected]="star <= rating"
[attr.aria-checked]="star === rating"
role="radio"
(click)="ratingChange.emit(star)">
</button>
}
</div>

View File

@@ -0,0 +1,24 @@
.star-selector {
display: inline-flex;
align-items: center;
gap: 6px;
}
.star-btn {
border: 0;
background: transparent;
color: #cad3d2;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
padding: 0;
transition: transform 0.2s ease, color 0.2s ease;
}
.star-btn:hover {
transform: translateY(-1px);
}
.star-btn.selected {
color: var(--primary-color);
}

View File

@@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'app-star-selector',
standalone: true,
templateUrl: './star-selector.component.html',
styleUrls: ['./star-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StarSelectorComponent {
@Input() rating = 0;
@Output() ratingChange = new EventEmitter<number>();
readonly stars = [1, 2, 3, 4, 5];
}

View File

@@ -0,0 +1,5 @@
<span class="stars" [class.sm]="size === 'sm'" [class.lg]="size === 'lg'" aria-label="Rating stars">
@for (star of stars; track star) {
<span class="star" [class.filled]="star <= rating"></span>
}
</span>

View File

@@ -0,0 +1,23 @@
.stars {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 1rem;
line-height: 1;
}
.star {
color: #cdd6d5;
}
.star.filled {
color: var(--primary-color);
}
.stars.sm {
font-size: 0.84rem;
}
.stars.lg {
font-size: 1.28rem;
}

View File

@@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'app-stars',
standalone: true,
templateUrl: './stars.component.html',
styleUrls: ['./stars.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StarsComponent {
@Input() rating = 0;
@Input() max = 5;
@Input() size: 'sm' | 'md' | 'lg' = 'md';
get stars(): number[] {
return Array.from({ length: this.max }, (_, index) => index + 1);
}
}