feat(product): implement sprint 9 product engagement module
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user