diff --git a/docs/Product-Details-Report.md b/docs/Product-Details-Report.md new file mode 100644 index 0000000..d9dcd5a --- /dev/null +++ b/docs/Product-Details-Report.md @@ -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. diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index fb6451d..876b2bc 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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', diff --git a/src/app/components/items-carousel/items-carousel.component.html b/src/app/components/items-carousel/items-carousel.component.html index 42aaeea..7a57445 100644 --- a/src/app/components/items-carousel/items-carousel.component.html +++ b/src/app/components/items-carousel/items-carousel.component.html @@ -16,7 +16,7 @@ [showIndicators]="true">
- +
@if (product.discount > 0) { diff --git a/src/app/components/product-card/product-card.component.html b/src/app/components/product-card/product-card.component.html index ab2406f..831dc2c 100644 --- a/src/app/components/product-card/product-card.component.html +++ b/src/app/components/product-card/product-card.component.html @@ -1,5 +1,5 @@
- +
@if (item.discount > 0) { diff --git a/src/app/features/website/catalog/containers/catalog-container.component.ts b/src/app/features/website/catalog/containers/catalog-container.component.ts index 6c050ea..56ec278 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.ts +++ b/src/app/features/website/catalog/containers/catalog-container.component.ts @@ -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 { diff --git a/src/app/features/website/product/components/delivery-information/delivery-information.component.html b/src/app/features/website/product/components/delivery-information/delivery-information.component.html new file mode 100644 index 0000000..26430b7 --- /dev/null +++ b/src/app/features/website/product/components/delivery-information/delivery-information.component.html @@ -0,0 +1,25 @@ +@if (hasDeliveryInfo) { +
+

{{ 'cart.deliveryLabel' | translate }}

+ + @if (product.deliveryMode === 'digital') { +
{{ 'cart.digitalDelivery' | translate }}
+ } @else { +
+ @for (option of product.deliveryOptions; track $index) { +
+
+ @if (option.deliveryPlace) { + {{ 'cart.deliveryPlace' | translate }}: {{ option.deliveryPlace }} + } + @if (option.deliveryTime) { + {{ 'cart.deliveryTime' | translate }}: {{ option.deliveryTime }} + } +
+ {{ option.deliveryPrice | number:'1.2-2' }} {{ currency }} +
+ } +
+ } +
+} diff --git a/src/app/features/website/product/components/delivery-information/delivery-information.component.scss b/src/app/features/website/product/components/delivery-information/delivery-information.component.scss new file mode 100644 index 0000000..253454f --- /dev/null +++ b/src/app/features/website/product/components/delivery-information/delivery-information.component.scss @@ -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; + } +} diff --git a/src/app/features/website/product/components/delivery-information/delivery-information.component.ts b/src/app/features/website/product/components/delivery-information/delivery-information.component.ts new file mode 100644 index 0000000..a0352e3 --- /dev/null +++ b/src/app/features/website/product/components/delivery-information/delivery-information.component.ts @@ -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; + } +} diff --git a/src/app/features/website/product/components/product-description/product-description.component.html b/src/app/features/website/product/components/product-description/product-description.component.html new file mode 100644 index 0000000..eef0350 --- /dev/null +++ b/src/app/features/website/product/components/product-description/product-description.component.html @@ -0,0 +1,21 @@ +
+

{{ 'itemDetail.description' | translate }}

+ + @if (simpleDescription) { +

{{ simpleDescription }}

+ } + + @if (descriptionFields.length > 0) { +

{{ 'itemDetail.specifications' | translate }}

+
+ @for (field of descriptionFields; track field.key) { +
+
{{ field.key }}
+
{{ field.value }}
+
+ } +
+ } @else if (product.description) { +

{{ product.description }}

+ } +
diff --git a/src/app/features/website/product/components/product-description/product-description.component.scss b/src/app/features/website/product/components/product-description/product-description.component.scss new file mode 100644 index 0000000..116cb2e --- /dev/null +++ b/src/app/features/website/product/components/product-description/product-description.component.scss @@ -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; + } +} diff --git a/src/app/features/website/product/components/product-description/product-description.component.ts b/src/app/features/website/product/components/product-description/product-description.component.ts new file mode 100644 index 0000000..5a10eca --- /dev/null +++ b/src/app/features/website/product/components/product-description/product-description.component.ts @@ -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 ?? []; + } +} diff --git a/src/app/features/website/product/components/product-gallery/product-gallery.component.html b/src/app/features/website/product/components/product-gallery/product-gallery.component.html new file mode 100644 index 0000000..2d44461 --- /dev/null +++ b/src/app/features/website/product/components/product-gallery/product-gallery.component.html @@ -0,0 +1,22 @@ + diff --git a/src/app/features/website/product/components/product-gallery/product-gallery.component.scss b/src/app/features/website/product/components/product-gallery/product-gallery.component.scss new file mode 100644 index 0000000..9aa01b5 --- /dev/null +++ b/src/app/features/website/product/components/product-gallery/product-gallery.component.scss @@ -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; +} diff --git a/src/app/features/website/product/components/product-gallery/product-gallery.component.ts b/src/app/features/website/product/components/product-gallery/product-gallery.component.ts new file mode 100644 index 0000000..f3c4ca4 --- /dev/null +++ b/src/app/features/website/product/components/product-gallery/product-gallery.component.ts @@ -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(); + + 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); + } +} diff --git a/src/app/features/website/product/components/product-information/product-information.component.html b/src/app/features/website/product/components/product-information/product-information.component.html new file mode 100644 index 0000000..71cfecb --- /dev/null +++ b/src/app/features/website/product/components/product-information/product-information.component.html @@ -0,0 +1,42 @@ +
+

{{ title || product.name }}

+ + @if (product.badges?.length) { +
+ @for (badge of product.badges; track badge) { + {{ badge }} + } +
+ } + + @if (product.rating) { +
+ {{ product.rating | number:'1.1-1' }} + ({{ product.callbacks?.length || product.comments?.length || 0 }}) +
+ } + +
+ @if (product.discount > 0) { +
+ {{ price | number:'1.2-2' }} {{ currency }} + -{{ product.discount }}% +
+ {{ discountedPrice | number:'1.2-2' }} {{ currency }} + } @else { + {{ price | number:'1.2-2' }} {{ currency }} + } +
+ +
+ + {{ 'itemDetail.stock' | translate }} + @if (remaining != null) { + {{ remaining }} + } +
+ + +
diff --git a/src/app/features/website/product/components/product-information/product-information.component.scss b/src/app/features/website/product/components/product-information/product-information.component.scss new file mode 100644 index 0000000..eaa6c4b --- /dev/null +++ b/src/app/features/website/product/components/product-information/product-information.component.scss @@ -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; + } +} diff --git a/src/app/features/website/product/components/product-information/product-information.component.ts b/src/app/features/website/product/components/product-information/product-information.component.ts new file mode 100644 index 0000000..f499c43 --- /dev/null +++ b/src/app/features/website/product/components/product-information/product-information.component.ts @@ -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(); + + readonly getBadgeClass = getBadgeClass; +} diff --git a/src/app/features/website/product/components/related-products/related-products.component.html b/src/app/features/website/product/components/related-products/related-products.component.html new file mode 100644 index 0000000..593f890 --- /dev/null +++ b/src/app/features/website/product/components/related-products/related-products.component.html @@ -0,0 +1,17 @@ +@if (products.length > 0) { + +} diff --git a/src/app/features/website/product/components/related-products/related-products.component.scss b/src/app/features/website/product/components/related-products/related-products.component.scss new file mode 100644 index 0000000..b3756b3 --- /dev/null +++ b/src/app/features/website/product/components/related-products/related-products.component.scss @@ -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; + } +} diff --git a/src/app/features/website/product/components/related-products/related-products.component.ts b/src/app/features/website/product/components/related-products/related-products.component.ts new file mode 100644 index 0000000..8ae5860 --- /dev/null +++ b/src/app/features/website/product/components/related-products/related-products.component.ts @@ -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(); + @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()); + } +} diff --git a/src/app/features/website/product/components/variant-selector/variant-selector.component.html b/src/app/features/website/product/components/variant-selector/variant-selector.component.html new file mode 100644 index 0000000..0ae2530 --- /dev/null +++ b/src/app/features/website/product/components/variant-selector/variant-selector.component.html @@ -0,0 +1,35 @@ +@if (hasVariants) { +
+ @if (colours.length > 0) { +
+ {{ 'itemDetail.colour' | translate }} +
+ @for (colour of colours; track colour) { + + } +
+
+ } @else if (fallbackColour) { +
+ {{ 'itemDetail.colour' | translate }} + +
+ } + + @if (sizes.length > 0) { +
+ {{ 'itemDetail.size' | translate }} +
+ @for (size of sizes; track size) { + + } +
+
+ } @else if (visibleFallbackSize) { +
+ {{ 'itemDetail.size' | translate }} + {{ visibleFallbackSize }} +
+ } +
+} diff --git a/src/app/features/website/product/components/variant-selector/variant-selector.component.scss b/src/app/features/website/product/components/variant-selector/variant-selector.component.scss new file mode 100644 index 0000000..422e9af --- /dev/null +++ b/src/app/features/website/product/components/variant-selector/variant-selector.component.scss @@ -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; + } +} diff --git a/src/app/features/website/product/components/variant-selector/variant-selector.component.ts b/src/app/features/website/product/components/variant-selector/variant-selector.component.ts new file mode 100644 index 0000000..f8cb1f7 --- /dev/null +++ b/src/app/features/website/product/components/variant-selector/variant-selector.component.ts @@ -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(); + @Output() sizeSelected = new EventEmitter(); + + 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; + } +} diff --git a/src/app/features/website/product/containers/product-details-container.component.html b/src/app/features/website/product/containers/product-details-container.component.html new file mode 100644 index 0000000..f885c6b --- /dev/null +++ b/src/app/features/website/product/containers/product-details-container.component.html @@ -0,0 +1,70 @@ +
+ @if (loading()) { +
+
+

{{ 'productDetails.loading' | translate }}

+
+ } + + @if (error()) { +
+

{{ 'productDetails.errorTitle' | translate }}

+

{{ error()! | translate }}

+ +
+ } + + @if (missing() && !loading() && !error()) { +
+

{{ 'productDetails.missingTitle' | translate }}

+

{{ 'productDetails.missingDescription' | translate }}

+
{{ 'productDetails.backToCatalog' | translate }} +
+ } + + @if (product(); as currentProduct) { + @if (!loading() && !error()) { +
+ + +
+ + + + + +
+
+ + + + + } + } +
diff --git a/src/app/features/website/product/containers/product-details-container.component.scss b/src/app/features/website/product/containers/product-details-container.component.scss new file mode 100644 index 0000000..6cbdb1b --- /dev/null +++ b/src/app/features/website/product/containers/product-details-container.component.scss @@ -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; + } +} diff --git a/src/app/features/website/product/containers/product-details-container.component.ts b/src/app/features/website/product/containers/product-details-container.component.ts new file mode 100644 index 0000000..44896ac --- /dev/null +++ b/src/app/features/website/product/containers/product-details-container.component.ts @@ -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(null); + readonly relatedProducts = signal([]); + readonly selectedPhotoIndex = signal(0); + readonly selectedColour = signal(null); + readonly selectedSize = signal(null); + readonly loading = signal(true); + readonly error = signal(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([]), + }); + } +} diff --git a/src/app/pages/cart/cart.component.html b/src/app/pages/cart/cart.component.html index c091060..f6db6b9 100644 --- a/src/app/pages/cart/cart.component.html +++ b/src/app/pages/cart/cart.component.html @@ -30,13 +30,13 @@ [class.swiped]="swipedItemId() === item.itemID" (touchstart)="onSwipeStart(item.itemID, $event)">
- +
- {{ itemName(item) }} + {{ itemName(item) }}