product details implementation
This commit is contained in:
@@ -111,7 +111,7 @@ export class CatalogContainerComponent {
|
||||
}
|
||||
|
||||
selectProduct(product: Product): void {
|
||||
this.router.navigate([`/${this.languageService.currentLanguage()}/item`, product.itemID]);
|
||||
this.router.navigate([`/${this.languageService.currentLanguage()}/product`, product.itemID]);
|
||||
}
|
||||
|
||||
addToCart(payload: { product: Product; event: Event }): void {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
@if (hasDeliveryInfo) {
|
||||
<section class="delivery-information">
|
||||
<h2>{{ 'cart.deliveryLabel' | translate }}</h2>
|
||||
|
||||
@if (product.deliveryMode === 'digital') {
|
||||
<div class="delivery-chip">{{ 'cart.digitalDelivery' | translate }}</div>
|
||||
} @else {
|
||||
<div class="delivery-list">
|
||||
@for (option of product.deliveryOptions; track $index) {
|
||||
<div class="delivery-option">
|
||||
<div>
|
||||
@if (option.deliveryPlace) {
|
||||
<span>{{ 'cart.deliveryPlace' | translate }}: {{ option.deliveryPlace }}</span>
|
||||
}
|
||||
@if (option.deliveryTime) {
|
||||
<span>{{ 'cart.deliveryTime' | translate }}: {{ option.deliveryTime }}</span>
|
||||
}
|
||||
</div>
|
||||
<strong>{{ option.deliveryPrice | number:'1.2-2' }} {{ currency }}</strong>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.delivery-information {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #d3dad9;
|
||||
border-radius: 13px;
|
||||
background: #f8faf9;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #1e3c38;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
.delivery-chip {
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(73, 118, 113, 0.12);
|
||||
color: #3d635f;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.delivery-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.delivery-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
color: #697777;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #1e3c38;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-delivery-information',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, TranslatePipe],
|
||||
templateUrl: './delivery-information.component.html',
|
||||
styleUrls: ['./delivery-information.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProductDeliveryInformationComponent {
|
||||
@Input({ required: true }) product!: Product;
|
||||
@Input() currency = '';
|
||||
|
||||
get hasDeliveryInfo(): boolean {
|
||||
return this.product.deliveryMode === 'digital' || !!this.product.deliveryOptions?.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<section class="product-description">
|
||||
<h2>{{ 'itemDetail.description' | translate }}</h2>
|
||||
|
||||
@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>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,56 @@
|
||||
.product-description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1e3c38;
|
||||
}
|
||||
|
||||
h2 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-description',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './product-description.component.html',
|
||||
styleUrls: ['./product-description.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProductDescriptionComponent {
|
||||
@Input({ required: true }) product!: Product;
|
||||
@Input() simpleDescription = '';
|
||||
|
||||
get descriptionFields() {
|
||||
return this.product.descriptionFields ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<section class="product-gallery" aria-label="Product media">
|
||||
<div class="product-gallery-main">
|
||||
@if (media[selectedIndex]?.video) {
|
||||
<video [src]="media[selectedIndex].url" controls></video>
|
||||
} @else {
|
||||
<img [src]="media[selectedIndex].url" [alt]="product.name" loading="eager" decoding="async" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (media.length > 1) {
|
||||
<div class="product-gallery-thumbs">
|
||||
@for (item of media; track $index) {
|
||||
<button type="button" class="product-gallery-thumb" [class.active]="selectedIndex === $index" (click)="select($index)">
|
||||
@if (item.video) {
|
||||
<span class="product-gallery-video">▶</span>
|
||||
}
|
||||
<img [src]="item.url" [alt]="product.name + ' ' + ($index + 1)" loading="lazy" decoding="async" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,61 @@
|
||||
.product-gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-gallery-main {
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid #d3dad9;
|
||||
border-radius: 13px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img,
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.product-gallery-thumbs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.product-gallery-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid #d3dad9;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
border-color: #497671;
|
||||
box-shadow: 0 0 0 3px rgba(73, 118, 113, 0.18);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.product-gallery-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(30, 60, 56, 0.48);
|
||||
color: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { getMainImage } from '../../../../../utils/item.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-gallery',
|
||||
standalone: true,
|
||||
templateUrl: './product-gallery.component.html',
|
||||
styleUrls: ['./product-gallery.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProductGalleryComponent {
|
||||
@Input({ required: true }) product!: Product;
|
||||
@Input() selectedIndex = 0;
|
||||
|
||||
@Output() selectedIndexChange = new EventEmitter<number>();
|
||||
|
||||
readonly getMainImage = getMainImage;
|
||||
|
||||
get media() {
|
||||
return this.product.photos?.length ? this.product.photos : [{ url: this.getMainImage(this.product) }];
|
||||
}
|
||||
|
||||
select(index: number): void {
|
||||
this.selectedIndexChange.emit(index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<section class="product-information">
|
||||
<h1>{{ title || product.name }}</h1>
|
||||
|
||||
@if (product.badges?.length) {
|
||||
<div class="product-badges">
|
||||
@for (badge of product.badges; track badge) {
|
||||
<span class="product-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (product.rating) {
|
||||
<div class="product-rating">
|
||||
<span>{{ product.rating | number:'1.1-1' }}</span>
|
||||
<span>({{ product.callbacks?.length || product.comments?.length || 0 }})</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="product-price">
|
||||
@if (product.discount > 0) {
|
||||
<div class="price-row">
|
||||
<span class="old-price">{{ price | number:'1.2-2' }} {{ currency }}</span>
|
||||
<span class="discount-badge">-{{ product.discount }}%</span>
|
||||
</div>
|
||||
<strong>{{ discountedPrice | number:'1.2-2' }} {{ currency }}</strong>
|
||||
} @else {
|
||||
<strong>{{ price | number:'1.2-2' }} {{ currency }}</strong>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="product-stock" [class]="stockStatus">
|
||||
<span class="stock-dot"></span>
|
||||
<span>{{ 'itemDetail.stock' | translate }}</span>
|
||||
@if (remaining != null) {
|
||||
<strong>{{ remaining }}</strong>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="button" class="add-to-cart" (click)="addToCart.emit()">
|
||||
{{ 'itemDetail.addToCart' | translate }}
|
||||
</button>
|
||||
</section>
|
||||
@@ -0,0 +1,106 @@
|
||||
.product-information {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #1e3c38;
|
||||
font-size: clamp(1.8rem, 4vw, 2.8rem);
|
||||
line-height: 1.08;
|
||||
}
|
||||
}
|
||||
|
||||
.product-badges,
|
||||
.product-rating,
|
||||
.price-row,
|
||||
.product-stock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.product-badge,
|
||||
.discount-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: #497671;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.discount-badge {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.product-rating {
|
||||
color: #697777;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
color: #1e3c38;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.old-price {
|
||||
color: #a1b4b5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
color: #697777;
|
||||
font-weight: 800;
|
||||
|
||||
&.out {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&.low {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.medium {
|
||||
color: #497671;
|
||||
}
|
||||
|
||||
&.high {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.stock-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.add-to-cart {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
border: 0;
|
||||
border-radius: 13px;
|
||||
background: #497671;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #3d635f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { getBadgeClass } from '../../../../../utils/item.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-information',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, TranslatePipe],
|
||||
templateUrl: './product-information.component.html',
|
||||
styleUrls: ['./product-information.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProductInformationComponent {
|
||||
@Input({ required: true }) product!: Product;
|
||||
@Input() title = '';
|
||||
@Input() price = 0;
|
||||
@Input() discountedPrice = 0;
|
||||
@Input() currency = '';
|
||||
@Input() remaining: number | null = null;
|
||||
@Input() stockStatus = 'high';
|
||||
|
||||
@Output() addToCart = new EventEmitter<void>();
|
||||
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@if (products.length > 0) {
|
||||
<section class="related-products">
|
||||
<h2>{{ 'itemDetail.relatedProducts' | translate }}</h2>
|
||||
<div class="related-grid">
|
||||
@for (product of products; track trackByItemId($index, product)) {
|
||||
<app-product-card
|
||||
[item]="product"
|
||||
[title]="productTitle(product)"
|
||||
appearance="compact"
|
||||
[addToCartLabel]="'itemDetail.addToCart' | translate"
|
||||
(selected)="productSelected.emit(product)"
|
||||
(addToCart)="addToCart.emit({ product, event: $event.event })"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.related-products {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #1e3c38;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
.related-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.related-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { ProductCardComponent } from '../../../../../components/product-card/product-card.component';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../../services/language.service';
|
||||
import { getTranslatedField, trackByItemId } from '../../../../../utils/item.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-related-products',
|
||||
standalone: true,
|
||||
imports: [ProductCardComponent, TranslatePipe],
|
||||
templateUrl: './related-products.component.html',
|
||||
styleUrls: ['./related-products.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RelatedProductsComponent {
|
||||
@Input() products: Product[] = [];
|
||||
|
||||
@Output() productSelected = new EventEmitter<Product>();
|
||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
readonly trackByItemId = trackByItemId;
|
||||
|
||||
productTitle(product: Product): string {
|
||||
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@if (hasVariants) {
|
||||
<section class="variant-selector">
|
||||
@if (colours.length > 0) {
|
||||
<div class="variant-group">
|
||||
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span>
|
||||
<div class="variant-options">
|
||||
@for (colour of colours; track colour) {
|
||||
<button type="button" class="colour-swatch" [class.active]="selectedColour === colour" [style.background-color]="colour" [attr.aria-label]="colour" (click)="colourSelected.emit(colour)"></button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (fallbackColour) {
|
||||
<div class="variant-group">
|
||||
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span>
|
||||
<span class="colour-swatch readonly" [style.background-color]="fallbackColour"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sizes.length > 0) {
|
||||
<div class="variant-group">
|
||||
<span class="variant-label">{{ 'itemDetail.size' | translate }}</span>
|
||||
<div class="variant-options">
|
||||
@for (size of sizes; track size) {
|
||||
<button type="button" class="size-chip" [class.active]="selectedSize === size" (click)="sizeSelected.emit(size)">{{ size }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (visibleFallbackSize) {
|
||||
<div class="variant-group">
|
||||
<span class="variant-label">{{ 'itemDetail.size' | translate }}</span>
|
||||
<span class="size-chip readonly">{{ visibleFallbackSize }}</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
.variant-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.variant-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.variant-label {
|
||||
color: #697777;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.variant-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.colour-swatch {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 2px solid #d3dad9;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
border-color: #1e3c38;
|
||||
box-shadow: 0 0 0 3px rgba(73, 118, 113, 0.2);
|
||||
}
|
||||
|
||||
&.readonly {
|
||||
display: inline-block;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.size-chip {
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #d3dad9;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #1e3c38;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
border-color: #497671;
|
||||
background: #497671;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.readonly {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-variant-selector',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './variant-selector.component.html',
|
||||
styleUrls: ['./variant-selector.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProductVariantSelectorComponent {
|
||||
@Input() colours: string[] = [];
|
||||
@Input() sizes: string[] = [];
|
||||
@Input() selectedColour: string | null = null;
|
||||
@Input() selectedSize: string | null = null;
|
||||
@Input() fallbackColour: string | null = null;
|
||||
@Input() fallbackSize: string | null = null;
|
||||
|
||||
@Output() colourSelected = new EventEmitter<string>();
|
||||
@Output() sizeSelected = new EventEmitter<string>();
|
||||
|
||||
get hasVariants(): boolean {
|
||||
return this.colours.length > 0 || this.sizes.length > 0 || !!this.fallbackColour || !!this.visibleFallbackSize;
|
||||
}
|
||||
|
||||
get visibleFallbackSize(): string | null {
|
||||
return this.fallbackSize && this.fallbackSize.toLowerCase() !== 'default' ? this.fallbackSize : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<main class="product-details-page">
|
||||
@if (loading()) {
|
||||
<section class="product-details-loading">
|
||||
<div class="product-details-spinner"></div>
|
||||
<p>{{ 'productDetails.loading' | translate }}</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<section class="product-details-message error">
|
||||
<h1>{{ 'productDetails.errorTitle' | translate }}</h1>
|
||||
<p>{{ error()! | translate }}</p>
|
||||
<button type="button" (click)="retry()">{{ 'productDetails.retry' | translate }}</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (missing() && !loading() && !error()) {
|
||||
<section class="product-details-message">
|
||||
<h1>{{ 'productDetails.missingTitle' | translate }}</h1>
|
||||
<p>{{ 'productDetails.missingDescription' | translate }}</p>
|
||||
<a [routerLink]="'/catalog' | langRoute">{{ 'productDetails.backToCatalog' | translate }}</a>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (product(); as currentProduct) {
|
||||
@if (!loading() && !error()) {
|
||||
<section class="product-details-layout">
|
||||
<app-product-gallery
|
||||
[product]="currentProduct"
|
||||
[selectedIndex]="selectedPhotoIndex()"
|
||||
(selectedIndexChange)="selectedPhotoIndex.set($event)"
|
||||
/>
|
||||
|
||||
<div class="product-details-main">
|
||||
<app-product-information
|
||||
[product]="currentProduct"
|
||||
[title]="productTitle(currentProduct)"
|
||||
[price]="effectivePrice()"
|
||||
[discountedPrice]="discountedPrice()"
|
||||
[currency]="effectiveCurrency()"
|
||||
[remaining]="effectiveRemaining()"
|
||||
[stockStatus]="stockStatus()"
|
||||
(addToCart)="addToCart()"
|
||||
/>
|
||||
|
||||
<app-product-variant-selector
|
||||
[colours]="availableColours()"
|
||||
[sizes]="availableSizes()"
|
||||
[selectedColour]="selectedColour()"
|
||||
[selectedSize]="selectedSize()"
|
||||
[fallbackColour]="currentProduct.colour ?? null"
|
||||
[fallbackSize]="currentProduct.size ?? null"
|
||||
(colourSelected)="selectColour($event)"
|
||||
(sizeSelected)="selectSize($event)"
|
||||
/>
|
||||
|
||||
<app-product-delivery-information [product]="currentProduct" [currency]="effectiveCurrency()" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<app-product-description [product]="currentProduct" [simpleDescription]="productDescription(currentProduct)" />
|
||||
|
||||
<app-related-products
|
||||
[products]="relatedProducts()"
|
||||
(productSelected)="selectRelatedProduct($event)"
|
||||
(addToCart)="addRelatedToCart($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</main>
|
||||
@@ -0,0 +1,87 @@
|
||||
.product-details-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 42px;
|
||||
}
|
||||
|
||||
.product-details-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.95fr) minmax(320px, 1fr);
|
||||
gap: 42px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.product-details-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.product-details-loading,
|
||||
.product-details-message {
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
color: #697777;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #1e3c38;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
min-height: 42px;
|
||||
padding: 0 22px;
|
||||
border: 0;
|
||||
border-radius: 13px;
|
||||
background: #497671;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.product-details-message.error {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.product-details-spinner {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 4px solid #d3dad9;
|
||||
border-top-color: #497671;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.product-details-page {
|
||||
padding: 16px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.product-details-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { Product } from '../../../../core/products/models/product-domain.model';
|
||||
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 { 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 { RelatedProductsComponent } from '../components/related-products/related-products.component';
|
||||
import { ProductVariantSelectorComponent } from '../components/variant-selector/variant-selector.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-details-container',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
LangRoutePipe,
|
||||
TranslatePipe,
|
||||
ProductGalleryComponent,
|
||||
ProductInformationComponent,
|
||||
ProductVariantSelectorComponent,
|
||||
ProductDeliveryInformationComponent,
|
||||
ProductDescriptionComponent,
|
||||
RelatedProductsComponent,
|
||||
],
|
||||
templateUrl: './product-details-container.component.html',
|
||||
styleUrls: ['./product-details-container.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProductDetailsContainerComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly productFacade = inject(ProductFacade);
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
readonly product = signal<Product | null>(null);
|
||||
readonly relatedProducts = signal<Product[]>([]);
|
||||
readonly selectedPhotoIndex = signal(0);
|
||||
readonly selectedColour = signal<string | null>(null);
|
||||
readonly selectedSize = signal<string | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly missing = signal(false);
|
||||
|
||||
readonly availableColours = computed(() => {
|
||||
const details = this.product()?.itemDetails;
|
||||
if (!details?.length) return [] as string[];
|
||||
return [...new Set(details.map(detail => detail.colour || detail.color).filter((colour): colour is string => !!colour))];
|
||||
});
|
||||
|
||||
readonly availableSizes = computed(() => {
|
||||
const details = this.product()?.itemDetails;
|
||||
if (!details?.length) return [] as string[];
|
||||
const colour = this.selectedColour();
|
||||
const filtered = colour ? details.filter(detail => (detail.colour || detail.color) === colour) : details;
|
||||
return [...new Set(filtered.map(detail => detail.size).filter((size): size is string => !!size && size.toLowerCase() !== 'default'))];
|
||||
});
|
||||
|
||||
readonly selectedDetail = computed(() => {
|
||||
const details = this.product()?.itemDetails;
|
||||
if (!details?.length) return null;
|
||||
const colour = this.selectedColour();
|
||||
const size = this.selectedSize();
|
||||
return details.find(detail =>
|
||||
(!colour || (detail.colour || detail.color) === colour) &&
|
||||
(!size || detail.size === size)
|
||||
) ?? null;
|
||||
});
|
||||
|
||||
readonly effectivePrice = computed(() => this.selectedDetail()?.price ?? this.product()?.price ?? 0);
|
||||
readonly effectiveCurrency = computed(() => this.selectedDetail()?.currency ?? this.product()?.currency ?? '');
|
||||
readonly effectiveRemaining = computed(() => this.selectedDetail()?.remaining ?? this.product()?.quantity ?? null);
|
||||
readonly discountedPrice = computed(() => {
|
||||
const current = this.product();
|
||||
if (!current) return 0;
|
||||
return this.effectivePrice() * (1 - (current.discount || 0) / 100);
|
||||
});
|
||||
readonly stockStatus = computed(() => {
|
||||
const current = this.product();
|
||||
if (!current) return 'high';
|
||||
const remaining = this.effectiveRemaining();
|
||||
if (remaining == null) return getStockStatus(current);
|
||||
if (remaining <= 0) return 'out';
|
||||
if (remaining <= 5) return 'low';
|
||||
if (remaining <= 20) return 'medium';
|
||||
return 'high';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(params => this.loadProduct(Number(params.get('id'))));
|
||||
}
|
||||
|
||||
loadProduct(productId: number): void {
|
||||
if (!productId) {
|
||||
this.missing.set(true);
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.missing.set(false);
|
||||
this.product.set(null);
|
||||
this.relatedProducts.set([]);
|
||||
this.selectedPhotoIndex.set(0);
|
||||
|
||||
this.productFacade.getProduct(productId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: product => {
|
||||
if (!product) {
|
||||
this.missing.set(true);
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.product.set(product);
|
||||
this.initVariantSelection(product);
|
||||
this.loadRelatedProducts(product);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('productDetails.error');
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectColour(colour: string): void {
|
||||
this.selectedColour.set(colour);
|
||||
const sizes = this.availableSizes();
|
||||
if (sizes.length && this.selectedSize() && !sizes.includes(this.selectedSize()!)) {
|
||||
this.selectedSize.set(sizes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
selectSize(size: string): void {
|
||||
this.selectedSize.set(size);
|
||||
}
|
||||
|
||||
addToCart(): void {
|
||||
const current = this.product();
|
||||
if (!current) return;
|
||||
this.cartService.addItem(current.itemID, 1, {
|
||||
colour: this.selectedColour() ?? undefined,
|
||||
size: this.selectedSize() ?? undefined,
|
||||
price: this.effectivePrice(),
|
||||
currency: this.effectiveCurrency(),
|
||||
});
|
||||
}
|
||||
|
||||
addRelatedToCart(payload: { product: Product; event: Event }): void {
|
||||
payload.event.preventDefault();
|
||||
payload.event.stopPropagation();
|
||||
this.cartService.addItem(payload.product.itemID);
|
||||
}
|
||||
|
||||
selectRelatedProduct(product: Product): void {
|
||||
this.router.navigate([`/${this.languageService.currentLanguage()}/product`, product.itemID]);
|
||||
}
|
||||
|
||||
productTitle(product: Product): string {
|
||||
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
|
||||
}
|
||||
|
||||
productDescription(product: Product): string {
|
||||
return getTranslatedField(product, 'simpleDescription', this.languageService.currentLanguage());
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
const productId = Number(this.route.snapshot.paramMap.get('id'));
|
||||
this.loadProduct(productId);
|
||||
}
|
||||
|
||||
private initVariantSelection(product: Product): void {
|
||||
const details = product.itemDetails;
|
||||
if (details?.length) {
|
||||
this.selectedColour.set(details[0].colour || details[0].color || null);
|
||||
this.selectedSize.set(details[0].size && details[0].size.toLowerCase() !== 'default' ? details[0].size : null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedColour.set(product.colour ?? null);
|
||||
this.selectedSize.set(product.size && product.size.toLowerCase() !== 'default' ? product.size : null);
|
||||
}
|
||||
|
||||
private loadRelatedProducts(product: Product): void {
|
||||
this.productFacade.getRelatedProducts({
|
||||
productID: product.itemID,
|
||||
categoryID: product.categoryID,
|
||||
count: 8,
|
||||
}).pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: result => this.relatedProducts.set(result.items.filter(item => item.itemID !== product.itemID)),
|
||||
error: () => this.relatedProducts.set([]),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user