product details implementation
This commit is contained in:
157
docs/Product-Details-Report.md
Normal file
157
docs/Product-Details-Report.md
Normal 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.
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([]),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user