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,157 @@
# Product Details Report
## Scope
Sprint 6 implemented the product details route and UI using the frozen architecture and the existing Product Domain. No authentication, payment, bootstrap, or backend API contracts were changed.
The route and UI consume ProductFacade only. The new product details module does not use DTOs, HttpClient, environment configuration, or storage.
## Implemented Route
- `/product/:id`
Legacy `/item/:id` now redirects to `/product/:id` for compatibility.
## Implemented Module
### Product Details Container
- `src/app/features/website/product/containers/product-details-container.component.ts`
- `src/app/features/website/product/containers/product-details-container.component.html`
- `src/app/features/website/product/containers/product-details-container.component.scss`
Responsibilities implemented:
- Reads the route id.
- Loads the product through `ProductFacade` only.
- Handles loading, error, and missing-product states.
- Computes and passes the Product Domain Model to reusable child components.
- Loads related products from the same category through `ProductFacade`.
- Supports variant selection and computed price/stock updates.
### Reusable Components
#### Product Gallery
- `src/app/features/website/product/components/product-gallery/product-gallery.component.ts`
- `src/app/features/website/product/components/product-gallery/product-gallery.component.html`
- `src/app/features/website/product/components/product-gallery/product-gallery.component.scss`
Features:
- Main image
- Thumbnail list
- Video-aware media rendering
- Future multi-image support through the existing media array shape
#### Product Information
- `src/app/features/website/product/components/product-information/product-information.component.ts`
- `src/app/features/website/product/components/product-information/product-information.component.html`
- `src/app/features/website/product/components/product-information/product-information.component.scss`
Features:
- Title
- Price
- Discount
- Badges
- Stock
- Add-to-cart output
#### Variant Selector
- `src/app/features/website/product/components/variant-selector/variant-selector.component.ts`
- `src/app/features/website/product/components/variant-selector/variant-selector.component.html`
- `src/app/features/website/product/components/variant-selector/variant-selector.component.scss`
Features:
- Color selection
- Size selection
- Fallback display for single-variant products
#### Delivery Information
- `src/app/features/website/product/components/delivery-information/delivery-information.component.ts`
- `src/app/features/website/product/components/delivery-information/delivery-information.component.html`
- `src/app/features/website/product/components/delivery-information/delivery-information.component.scss`
Features:
- Digital delivery state
- Delivery option list
- Currency display
#### Product Description
- `src/app/features/website/product/components/product-description/product-description.component.ts`
- `src/app/features/website/product/components/product-description/product-description.component.html`
- `src/app/features/website/product/components/product-description/product-description.component.scss`
Features:
- Simple description
- Structured specification fields
- Plain description fallback
#### Related Products
- `src/app/features/website/product/components/related-products/related-products.component.ts`
- `src/app/features/website/product/components/related-products/related-products.component.html`
- `src/app/features/website/product/components/related-products/related-products.component.scss`
Features:
- Uses existing ProductFacade results
- Excludes the current product
- Reuses the shared product card
## Product Domain Usage
All product details UI code uses Product Domain models and ProductFacade. Backend DTOs remain isolated to the domain/provider layer.
The container derives all view state locally from the product model:
- selected color
- selected size
- effective price
- effective currency
- effective remaining stock
- gallery selection
- related products
## Navigation Updates
Updated links to use the new route surface:
- product card navigation
- carousel item links
- cart item links
- category item links
- catalog module product navigation
## Validation
Completed checks:
- Product loads through `ProductFacade`.
- Variant changes update displayed price.
- Stock changes correctly when variant selection changes.
- Gallery works with primary and thumbnail media.
- Related products work through the same-category product query.
- Components are reusable and input/output-only.
- No DTO usage in the new product details module.
- No `HttpClient` in the new product details UI.
- No storage or environment usage in the new product details UI.
- Build passes.
Build validation:
```bash
npm run build
```
## Stop Point
Product Details Module implementation is complete for Sprint 6. Stop here for approval before starting any further module work.

View File

@@ -25,9 +25,14 @@ const coreRoutes: Routes = [
redirectTo: 'catalog/:id',
pathMatch: 'full'
},
{
path: 'product/:id',
loadComponent: () => import('./features/website/product/containers/product-details-container.component').then(m => m.ProductDetailsContainerComponent)
},
{
path: 'item/:id',
loadComponent: () => import('./pages/item-detail/item-detail.component').then(m => m.ItemDetailComponent)
redirectTo: 'product/:id',
pathMatch: 'full'
},
{
path: 'search',
@@ -44,7 +49,7 @@ const coreRoutes: Routes = [
// company-details, payment-terms, return-policy, public-offer, privacy-policy.
const cmsContentRoutes: Routes = [];
// All routes sit under a :lang prefix (e.g. /ru/cart, /en/item/5)
// All routes sit under a :lang prefix (e.g. /ru/cart, /en/product/5)
export const routes: Routes = [
{
path: ':lang',

View File

@@ -16,7 +16,7 @@
[showIndicators]="true">
<ng-template let-product pTemplate="item">
<div class="item-card">
<a [routerLink]="['/item', product.itemID] | langRoute" class="item-link">
<a [routerLink]="['/product', product.itemID] | langRoute" class="item-link">
<div class="item-image">
<img [src]="getItemImage(product)" [alt]="itemName(product)" loading="lazy" />
@if (product.discount > 0) {

View File

@@ -1,5 +1,5 @@
<article class="product-card" [class.product-card-compact]="appearance === 'compact'" (mouseenter)="preview.emit(item.itemID)">
<a [routerLink]="['/item', item.itemID] | langRoute" class="product-link" (click)="onSelected($event)">
<a [routerLink]="['/product', item.itemID] | langRoute" class="product-link" (click)="onSelected($event)">
<div class="product-image">
<img [src]="getMainImage(item)" [alt]="title || item.name" loading="lazy" decoding="async" width="300" height="300" />
@if (item.discount > 0) {

View File

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

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

View File

@@ -30,13 +30,13 @@
[class.swiped]="swipedItemId() === item.itemID"
(touchstart)="onSwipeStart(item.itemID, $event)">
<div class="cart-item">
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-image">
<a [routerLink]="['/product', item.itemID] | langRoute" class="item-image">
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" />
</a>
<div class="item-info">
<div class="item-header">
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-name">{{ itemName(item) }}</a>
<a [routerLink]="['/product', item.itemID] | langRoute" class="item-name">{{ itemName(item) }}</a>
<button class="remove-btn" (click)="removeItem(item)" title="Remove">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>

View File

@@ -54,7 +54,7 @@
<h3 class="items-section-title">{{ 'subcategories.itemsInCategory' | translate }}</h3>
<div class="items-grid">
@for (item of categoryItems(); track trackByItemId($index, item)) {
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-card">
<a [routerLink]="['/product', item.itemID] | langRoute" class="item-card">
<div class="item-image">
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" />
@if (item.discount > 0) {