From 78c3684c184d1296c028a0de60df267cbd4f2875 Mon Sep 17 00:00:00 2001 From: Jesus Navalon Date: Sun, 12 Jul 2026 18:55:55 +0200 Subject: [PATCH] feat: implement product detail and list components with search functionality --- src/app/app.config.ts | 6 +- src/app/app.routes.ts | 13 +- .../product-actions/product-actions.html | 32 +++++ .../product-actions/product-actions.scss | 15 +++ .../product-actions/product-actions.spec.ts | 23 ++++ .../product-actions/product-actions.ts | 55 ++++++++ .../product-description.html | 8 ++ .../product-description.scss | 21 +++ .../product-description.spec.ts | 23 ++++ .../product-description.ts | 43 ++++++ .../product-detail/product-detail.html | 24 ++++ .../product-detail/product-detail.scss | 34 +++++ .../product-detail/product-detail.spec.ts | 23 ++++ .../features/product-detail/product-detail.ts | 70 ++++++++++ .../product-image/product-image.html | 1 + .../product-image/product-image.scss | 8 ++ .../product-image/product-image.spec.ts | 23 ++++ .../product-image/product-image.ts | 11 ++ .../product-item/product-item.html | 19 +++ .../product-item/product-item.scss | 40 ++++++ .../product-item/product-item.spec.ts | 23 ++++ .../product-list/product-item/product-item.ts | 23 ++++ .../features/product-list/product-list.html | 27 ++++ .../features/product-list/product-list.scss | 72 ++++++++++ .../product-list/product-list.spec.ts | 23 ++++ src/app/features/product-list/product-list.ts | 124 ++++++++++++++++++ .../product-search/product-search.html | 5 + .../product-search/product-search.scss | 4 + .../product-search/product-search.spec.ts | 23 ++++ .../product-search/product-search.ts | 23 ++++ 30 files changed, 836 insertions(+), 3 deletions(-) create mode 100644 src/app/features/product-detail/product-actions/product-actions.html create mode 100644 src/app/features/product-detail/product-actions/product-actions.scss create mode 100644 src/app/features/product-detail/product-actions/product-actions.spec.ts create mode 100644 src/app/features/product-detail/product-actions/product-actions.ts create mode 100644 src/app/features/product-detail/product-description/product-description.html create mode 100644 src/app/features/product-detail/product-description/product-description.scss create mode 100644 src/app/features/product-detail/product-description/product-description.spec.ts create mode 100644 src/app/features/product-detail/product-description/product-description.ts create mode 100644 src/app/features/product-detail/product-detail.html create mode 100644 src/app/features/product-detail/product-detail.scss create mode 100644 src/app/features/product-detail/product-detail.spec.ts create mode 100644 src/app/features/product-detail/product-detail.ts create mode 100644 src/app/features/product-detail/product-image/product-image.html create mode 100644 src/app/features/product-detail/product-image/product-image.scss create mode 100644 src/app/features/product-detail/product-image/product-image.spec.ts create mode 100644 src/app/features/product-detail/product-image/product-image.ts create mode 100644 src/app/features/product-list/product-item/product-item.html create mode 100644 src/app/features/product-list/product-item/product-item.scss create mode 100644 src/app/features/product-list/product-item/product-item.spec.ts create mode 100644 src/app/features/product-list/product-item/product-item.ts create mode 100644 src/app/features/product-list/product-list.html create mode 100644 src/app/features/product-list/product-list.scss create mode 100644 src/app/features/product-list/product-list.spec.ts create mode 100644 src/app/features/product-list/product-list.ts create mode 100644 src/app/features/product-list/product-search/product-search.html create mode 100644 src/app/features/product-list/product-search/product-search.scss create mode 100644 src/app/features/product-list/product-search/product-search.spec.ts create mode 100644 src/app/features/product-list/product-search/product-search.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index d953f4c..83d53f3 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,12 +1,14 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; import { routes } from './app.routes'; +import {provideHttpClient} from '@angular/common/http'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes) + provideRouter(routes, withComponentInputBinding()), + provideHttpClient() ] }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dc39edb..e3b6daa 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,3 +1,14 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { + path: '', + loadComponent: () => import('./features/product-list/product-list').then((m) => m.ProductList) + }, + { + path: 'product/:id', + loadComponent: () => + import('./features/product-detail/product-detail').then((m) => m.ProductDetail) + }, + { path: '**', redirectTo: '' } +]; diff --git a/src/app/features/product-detail/product-actions/product-actions.html b/src/app/features/product-detail/product-actions/product-actions.html new file mode 100644 index 0000000..24ab403 --- /dev/null +++ b/src/app/features/product-detail/product-actions/product-actions.html @@ -0,0 +1,32 @@ +
+ + Colour + + @for (color of options().colors; track color.code) { + {{ color.name }} + } + + + + + Storage + + @for (storage of options().storages; track storage.code) { + {{ storage.name }} + } + + + + +
diff --git a/src/app/features/product-detail/product-actions/product-actions.scss b/src/app/features/product-detail/product-actions/product-actions.scss new file mode 100644 index 0000000..9a743e6 --- /dev/null +++ b/src/app/features/product-detail/product-actions/product-actions.scss @@ -0,0 +1,15 @@ +.product-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 1rem; + + mat-form-field { + width: 100%; + } + + &__submit { + align-self: flex-start; + margin-top: 0.5rem; + } +} diff --git a/src/app/features/product-detail/product-actions/product-actions.spec.ts b/src/app/features/product-detail/product-actions/product-actions.spec.ts new file mode 100644 index 0000000..96a4d89 --- /dev/null +++ b/src/app/features/product-detail/product-actions/product-actions.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProductActions } from './product-actions'; + +describe('ProductActions', () => { + let component: ProductActions; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProductActions] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProductActions); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/product-detail/product-actions/product-actions.ts b/src/app/features/product-detail/product-actions/product-actions.ts new file mode 100644 index 0000000..01dc258 --- /dev/null +++ b/src/app/features/product-detail/product-actions/product-actions.ts @@ -0,0 +1,55 @@ +import { Component, inject, input, linkedSignal, output, signal } from '@angular/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +import { ProductOptions } from '../../../core/models/product.model'; +import { CartService } from '../../../core/services/cart.service'; + +@Component({ + selector: 'app-product-actions', + imports: [MatFormFieldModule, MatSelectModule, MatButtonModule], + templateUrl: './product-actions.html', + styleUrl: './product-actions.scss' +}) +export class ProductActions { + private readonly cartService = inject(CartService); + private readonly snackBar = inject(MatSnackBar); + + readonly productId = input.required(); + readonly options = input.required(); + + readonly added = output(); + + readonly selectedColorCode = linkedSignal(() => this.options().colors[0]?.code); + + readonly selectedStorageCode = linkedSignal(() => this.options().storages[0]?.code); + + readonly adding = signal(false); + + async onAddToCart(): Promise { + const colorCode = this.selectedColorCode(); + const storageCode = this.selectedStorageCode(); + if (colorCode === undefined || storageCode === undefined) { + return; + } + + this.adding.set(true); + try { + await this.cartService.addToCart({ + id: this.productId(), + colorCode, + storageCode + }); + this.snackBar.open('Product added to cart', 'Close', { duration: 3000 }); + this.added.emit(); + } catch { + this.snackBar.open('We couldn\'t add the product to the cart', 'Close', { + duration: 3000 + }); + } finally { + this.adding.set(false); + } + } +} diff --git a/src/app/features/product-detail/product-description/product-description.html b/src/app/features/product-detail/product-description/product-description.html new file mode 100644 index 0000000..2bad271 --- /dev/null +++ b/src/app/features/product-detail/product-description/product-description.html @@ -0,0 +1,8 @@ +
+ @for (row of rows(); track row.label) { +
+
{{ row.label }}
+
{{ row.value }}
+
+ } +
diff --git a/src/app/features/product-detail/product-description/product-description.scss b/src/app/features/product-detail/product-description/product-description.scss new file mode 100644 index 0000000..436014e --- /dev/null +++ b/src/app/features/product-detail/product-description/product-description.scss @@ -0,0 +1,21 @@ +.product-description { + margin: 0; + + &__row { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + + dt { + font-weight: 600; + opacity: 0.7; + } + + dd { + margin: 0; + text-align: right; + } + } +} diff --git a/src/app/features/product-detail/product-description/product-description.spec.ts b/src/app/features/product-detail/product-description/product-description.spec.ts new file mode 100644 index 0000000..a655da5 --- /dev/null +++ b/src/app/features/product-detail/product-description/product-description.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProductDescription } from './product-description'; + +describe('ProductDescription', () => { + let component: ProductDescription; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProductDescription] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProductDescription); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/product-detail/product-description/product-description.ts b/src/app/features/product-detail/product-description/product-description.ts new file mode 100644 index 0000000..446ae4d --- /dev/null +++ b/src/app/features/product-detail/product-description/product-description.ts @@ -0,0 +1,43 @@ +import { Component, computed, input } from '@angular/core'; + +import { ProductDetail } from '../../../core/models/product.model'; + +// used to renderize rows on for each description value +interface DescriptionRow { + label: string; + value: string; +} + +@Component({ + selector: 'app-product-description', + templateUrl: './product-description.html', + styleUrl: './product-description.scss' +}) +export class ProductDescription { + + readonly product = input.required(); + + readonly rows = computed(() => { + const p = this.product(); + const cameras = [...(p.primaryCamera ?? []), ...(p.secondaryCmera ?? [])]; + + const candidates: DescriptionRow[] = [ + { label: 'Brand', value: p.brand }, + { label: 'Model', value: p.model }, + { label: 'Price', value: `${p.price} €` }, + { label: 'CPU', value: p.cpu ?? '' }, + { label: 'RAM', value: p.ram ?? '' }, + { label: 'OS', value: p.os ?? '' }, + { label: 'Display Type', value: p.displayType ?? '' }, + // Note: names on the API "displayResolution"/"displaySize" are swapped compared to their real content. + { label: 'Screen size', value: p.displayResolution ?? '' }, + { label: 'Resolution', value: p.displaySize ?? '' }, + { label: 'Battery', value: p.battery ?? '' }, + { label: 'Cameras', value: cameras.join(', ') }, + { label: 'Dimensions', value: p.dimentions ?? '' }, + { label: 'Weight', value: p.weight ?? '' } + ]; + + return candidates.filter((row) => row.value !== ''); + }); +} diff --git a/src/app/features/product-detail/product-detail.html b/src/app/features/product-detail/product-detail.html new file mode 100644 index 0000000..4d73418 --- /dev/null +++ b/src/app/features/product-detail/product-detail.html @@ -0,0 +1,24 @@ +
+ + arrow_back + Go back to the list + + + @if (loading()) { +
+ +
+ } @else if (error()) { +

{{ error() }}

+ } @else if (product(); as product) { +
+ + +
+

{{ product.brand }} {{ product.model }}

+ + +
+
+ } +
diff --git a/src/app/features/product-detail/product-detail.scss b/src/app/features/product-detail/product-detail.scss new file mode 100644 index 0000000..88d2b09 --- /dev/null +++ b/src/app/features/product-detail/product-detail.scss @@ -0,0 +1,34 @@ +.product-detail { + &__back { + margin-bottom: 1rem; + } + + &__columns { + display: grid; + grid-template-columns: minmax(240px, 360px) 1fr; + gap: 2rem; + } + + &__info { + h1 { + margin-top: 0; + } + } + + &__loading { + display: flex; + justify-content: center; + padding: 3rem; + } + + &__error { + text-align: center; + opacity: 0.7; + } + + @media (max-width: 768px) { + &__columns { + grid-template-columns: 1fr; + } + } +} diff --git a/src/app/features/product-detail/product-detail.spec.ts b/src/app/features/product-detail/product-detail.spec.ts new file mode 100644 index 0000000..6373d68 --- /dev/null +++ b/src/app/features/product-detail/product-detail.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProductDetail } from './product-detail'; + +describe('ProductDetail', () => { + let component: ProductDetail; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProductDetail] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProductDetail); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/product-detail/product-detail.ts b/src/app/features/product-detail/product-detail.ts new file mode 100644 index 0000000..b664d12 --- /dev/null +++ b/src/app/features/product-detail/product-detail.ts @@ -0,0 +1,70 @@ +import { Component, effect, inject, input, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +import { ProductService } from '../../core/services/product.service'; +import { ProductDetail as ProductDetailModel } from '../../core/models/product.model'; +import { ProductImage } from './product-image/product-image'; +import { ProductDescription } from './product-description/product-description'; +import { ProductActions } from './product-actions/product-actions'; + +@Component({ + selector: 'app-product-detail', + imports: [ + RouterLink, + MatProgressSpinnerModule, + MatButtonModule, + MatIconModule, + ProductImage, + ProductDescription, + ProductActions + ], + templateUrl: './product-detail.html', + styleUrl: './product-detail.scss' +}) +export class ProductDetail { + private readonly productService = inject(ProductService); + + //product id + readonly id = input.required(); + + // product detail + readonly product = signal(null); + + readonly loading = signal(true); + + readonly error = signal(null); + + constructor() { + effect((onCleanup) => { + const id = this.id(); + let cancelled = false; + onCleanup(() => { + cancelled = true; + }); + this.loadProduct(id, () => cancelled); + }); + } + + // Loads the product detail from the API and updates the signals accordingly. + private async loadProduct(id: string, isCancelled: () => boolean): Promise { + this.loading.set(true); + this.error.set(null); + try { + const product = await this.productService.getProductDetail(id); + if (!isCancelled()) { + this.product.set(product); + } + } catch { + if (!isCancelled()) { + this.error.set('Not able to load de item detail.'); + } + } finally { + if (!isCancelled()) { + this.loading.set(false); + } + } + } +} diff --git a/src/app/features/product-detail/product-image/product-image.html b/src/app/features/product-detail/product-image/product-image.html new file mode 100644 index 0000000..8d96fa8 --- /dev/null +++ b/src/app/features/product-detail/product-image/product-image.html @@ -0,0 +1 @@ + diff --git a/src/app/features/product-detail/product-image/product-image.scss b/src/app/features/product-detail/product-image/product-image.scss new file mode 100644 index 0000000..013b3c9 --- /dev/null +++ b/src/app/features/product-detail/product-image/product-image.scss @@ -0,0 +1,8 @@ +.product-image { + display: block; + width: 100%; + max-width: 360px; + aspect-ratio: 1 / 1; + object-fit: contain; + margin: 0 auto; +} diff --git a/src/app/features/product-detail/product-image/product-image.spec.ts b/src/app/features/product-detail/product-image/product-image.spec.ts new file mode 100644 index 0000000..65ec4f1 --- /dev/null +++ b/src/app/features/product-detail/product-image/product-image.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProductImage } from './product-image'; + +describe('ProductImage', () => { + let component: ProductImage; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProductImage] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProductImage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/product-detail/product-image/product-image.ts b/src/app/features/product-detail/product-image/product-image.ts new file mode 100644 index 0000000..04b320f --- /dev/null +++ b/src/app/features/product-detail/product-image/product-image.ts @@ -0,0 +1,11 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-product-image', + templateUrl: './product-image.html', + styleUrl: './product-image.scss' +}) +export class ProductImage { + readonly imgUrl = input.required(); + readonly alt = input.required(); +} diff --git a/src/app/features/product-list/product-item/product-item.html b/src/app/features/product-list/product-item/product-item.html new file mode 100644 index 0000000..5c982b2 --- /dev/null +++ b/src/app/features/product-list/product-item/product-item.html @@ -0,0 +1,19 @@ + + + +

{{ product().brand }}

+

{{ product().model }}

+

{{ product().price }} €

+
+
diff --git a/src/app/features/product-list/product-item/product-item.scss b/src/app/features/product-list/product-item/product-item.scss new file mode 100644 index 0000000..2a9b13a --- /dev/null +++ b/src/app/features/product-list/product-item/product-item.scss @@ -0,0 +1,40 @@ +.product-item { + display: flex; + flex-direction: column; + height: 100%; + cursor: pointer; + transition: + box-shadow 0.2s ease, + transform 0.2s ease; + + &:hover, + &:focus-visible { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transform: translateY(-2px); + } + + &__image { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: contain; + box-sizing: border-box; + padding: 1rem; + } + + &__brand { + margin: 0; + font-size: 0.8rem; + text-transform: uppercase; + opacity: 0.7; + } + + &__model { + margin: 0.25rem 0; + font-weight: 600; + } + + &__price { + margin: 0; + font-weight: 700; + } +} diff --git a/src/app/features/product-list/product-item/product-item.spec.ts b/src/app/features/product-list/product-item/product-item.spec.ts new file mode 100644 index 0000000..e766a4c --- /dev/null +++ b/src/app/features/product-list/product-item/product-item.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProductItem } from './product-item'; + +describe('ProductItem', () => { + let component: ProductItem; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProductItem] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProductItem); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/product-list/product-item/product-item.ts b/src/app/features/product-list/product-item/product-item.ts new file mode 100644 index 0000000..aca3656 --- /dev/null +++ b/src/app/features/product-list/product-item/product-item.ts @@ -0,0 +1,23 @@ +import { Component, input, output } from '@angular/core'; +import { MatCardModule } from '@angular/material/card'; + +import { Product } from '../../../core/models/product.model'; + +// Item visor +@Component({ + selector: 'app-product-item', + imports: [MatCardModule], + templateUrl: './product-item.html', + styleUrl: './product-item.scss' +}) +export class ProductItem { + // product to show in the card + readonly product = input.required(); + + // Event emited when product is selected. + readonly productSelected = output(); + + onSelect(): void { + this.productSelected.emit(this.product()); + } +} diff --git a/src/app/features/product-list/product-list.html b/src/app/features/product-list/product-list.html new file mode 100644 index 0000000..d5e37d5 --- /dev/null +++ b/src/app/features/product-list/product-list.html @@ -0,0 +1,27 @@ +
+
+

Product list

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

{{ error() }}

+ } @else if (filteredProducts().length === 0) { +

No products found.

+ } @else { +
+
+ @for (product of visibleProducts(); track product.id) { + + } +
+
+ } +
diff --git a/src/app/features/product-list/product-list.scss b/src/app/features/product-list/product-list.scss new file mode 100644 index 0000000..10ecb76 --- /dev/null +++ b/src/app/features/product-list/product-list.scss @@ -0,0 +1,72 @@ +:host { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} + +.product-list { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + width: 100%; + + &__toolbar { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + } + + + &__scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + } + + &__grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + padding-bottom: 1rem; + } + + &__loading, + &__error, + &__empty { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + } + + &__error, + &__empty { + text-align: center; + opacity: 0.7; + } + + // max 4 elements + @media (max-width: 1024px) { + &__grid { + grid-template-columns: repeat(3, 1fr); + } + } + + @media (max-width: 768px) { + &__grid { + grid-template-columns: repeat(2, 1fr); + } + } + + @media (max-width: 480px) { + &__grid { + grid-template-columns: 1fr; + } + } +} diff --git a/src/app/features/product-list/product-list.spec.ts b/src/app/features/product-list/product-list.spec.ts new file mode 100644 index 0000000..15c39f0 --- /dev/null +++ b/src/app/features/product-list/product-list.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProductList } from './product-list'; + +describe('ProductList', () => { + let component: ProductList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProductList] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProductList); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/product-list/product-list.ts b/src/app/features/product-list/product-list.ts new file mode 100644 index 0000000..235c70c --- /dev/null +++ b/src/app/features/product-list/product-list.ts @@ -0,0 +1,124 @@ +import { + Component, + ElementRef, + afterRenderEffect, + computed, + inject, + signal, + viewChild, OnInit +} from '@angular/core'; + +import { Router } from '@angular/router'; +import {MatProgressSpinner, MatProgressSpinnerModule} from '@angular/material/progress-spinner'; + +import { ProductService } from '../../core/services/product.service'; +import { Product } from '../../core/models/product.model'; +import { ProductSearch } from './product-search/product-search'; +import { ProductItem } from './product-item/product-item'; + +const PAGE_SIZE = 20; + +@Component({ + selector: 'app-product-list', + imports: [ + MatProgressSpinner, + ProductItem, + ProductSearch + ], + templateUrl: './product-list.html', + styleUrl: './product-list.scss', +}) +export class ProductList implements OnInit { + + private readonly productService = inject(ProductService); + private readonly router = inject(Router); + + // Scroll list where items will be displayed + private readonly scrollContainer = viewChild>('scrollContainer'); + + // raw list of items retrieved by api call + private readonly allProducts = signal([]); + + // search tearm on the search input + readonly searchTerm = signal(''); + + readonly loading = signal(true); + + readonly error = signal(null); + + // products on the infinite scroll list that are currently visible + readonly visibleCount = signal(PAGE_SIZE); + + //filtered products + readonly filteredProducts = computed(() => { + const term = this.searchTerm().trim().toLowerCase(); + if (!term) { + return this.allProducts(); + } + return this.allProducts().filter( + (product) => + product.brand.toLowerCase().includes(term) || product.model.toLowerCase().includes(term) + ); + }); + + // products that are currently visible on the list + readonly visibleProducts = computed(() => this.filteredProducts().slice(0, this.visibleCount())); + + readonly hasMore = computed(() => this.visibleCount() < this.filteredProducts().length); + + constructor() { + // small workaround to fix scroll heigh disaligned + afterRenderEffect(() => { + this.visibleProducts(); + const el = this.scrollContainer()?.nativeElement; + if (el && this.hasMore() && el.scrollHeight <= el.clientHeight) { + this.loadMore(); + } + }); + } + + ngOnInit() { + this.loadProducts(); + + + } + + onSearchTermChange(term: string): void { + this.searchTerm.set(term); + this.visibleCount.set(PAGE_SIZE); + } + + // navigate to product detail page when users clic and item + onSelectProduct(product: Product): void { + this.router.navigate(['/product', product.id]); + } + + // charge items when scroll is close to the end of the list and still pending items to show + onScroll(event: Event): void { + const el = event.target as HTMLElement; + const scrollThresholdPx = 200; + const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - scrollThresholdPx; + if (nearBottom && this.hasMore()) { + this.loadMore(); + } + } + + // load products from the api and set the allProducts signal + private async loadProducts(): Promise { + try { + const products = await this.productService.getProducts(); + this.allProducts.set(products); + } catch { + this.error.set('No se ha podido cargar el listado de productos.'); + } finally { + this.loading.set(false); + } + } + + // load more products to the visible list when user scrolls down + private loadMore(): void { + this.visibleCount.update((count) => + Math.min(count + PAGE_SIZE, this.filteredProducts().length) + ); + } +} diff --git a/src/app/features/product-list/product-search/product-search.html b/src/app/features/product-list/product-search/product-search.html new file mode 100644 index 0000000..9bd255e --- /dev/null +++ b/src/app/features/product-list/product-search/product-search.html @@ -0,0 +1,5 @@ + + Find by brand or model + + search + diff --git a/src/app/features/product-list/product-search/product-search.scss b/src/app/features/product-list/product-search/product-search.scss new file mode 100644 index 0000000..d5118d7 --- /dev/null +++ b/src/app/features/product-list/product-search/product-search.scss @@ -0,0 +1,4 @@ +.product-search { + width: 100%; + max-width: 320px; +} diff --git a/src/app/features/product-list/product-search/product-search.spec.ts b/src/app/features/product-list/product-search/product-search.spec.ts new file mode 100644 index 0000000..232062b --- /dev/null +++ b/src/app/features/product-list/product-search/product-search.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProductSearch } from './product-search'; + +describe('ProductSearch', () => { + let component: ProductSearch; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProductSearch] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProductSearch); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/product-list/product-search/product-search.ts b/src/app/features/product-list/product-search/product-search.ts new file mode 100644 index 0000000..61c18bd --- /dev/null +++ b/src/app/features/product-list/product-search/product-search.ts @@ -0,0 +1,23 @@ +import { Component, output } from '@angular/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatIconModule } from '@angular/material/icon'; + +//search bar +@Component({ + selector: 'app-product-search', + imports: [MatFormFieldModule, MatInputModule, MatIconModule], + templateUrl: './product-search.html', + styleUrl: './product-search.scss' +}) +export class ProductSearch { + + // Emits the search event + readonly searchChange = output(); + + // Extracts the value from the input and emits it to the parent component + onInput(event: Event): void { + const value = (event.target as HTMLInputElement).value; + this.searchChange.emit(value); + } +}