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 @@
+
+
+
+ @if (loading()) {
+
+
+
+ } @else if (error()) {
+ {{ error() }}
+ } @else if (filteredProducts().length === 0) {
+ No products found.
+ } @else {
+
+ }
+
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);
+ }
+}