product details implementation

This commit is contained in:
sdarbinyan
2026-07-05 01:43:56 +04:00
parent 0efcfb5225
commit d4a5daeb4c
28 changed files with 1210 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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([]),
});
}
}