feat: implement product detail and list components with search functionality

This commit is contained in:
Jesus Navalon
2026-07-12 18:55:55 +02:00
parent 87279fea1c
commit 78c3684c18
30 changed files with 836 additions and 3 deletions
+4 -2
View File
@@ -1,12 +1,14 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import {provideHttpClient} from '@angular/common/http';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes) provideRouter(routes, withComponentInputBinding()),
provideHttpClient()
] ]
}; };
+12 -1
View File
@@ -1,3 +1,14 @@
import { Routes } from '@angular/router'; 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: '' }
];
@@ -0,0 +1,32 @@
<div class="product-actions">
<mat-form-field appearance="outline">
<mat-label>Colour</mat-label>
<mat-select [value]="selectedColorCode()" (selectionChange)="selectedColorCode.set($event.value)">
@for (color of options().colors; track color.code) {
<mat-option [value]="color.code">{{ color.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Storage</mat-label>
<mat-select
[value]="selectedStorageCode()"
(selectionChange)="selectedStorageCode.set($event.value)"
>
@for (storage of options().storages; track storage.code) {
<mat-option [value]="storage.code">{{ storage.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<button
mat-raised-button
color="primary"
class="product-actions__submit"
[disabled]="adding()"
(click)="onAddToCart()"
>
Añadir
</button>
</div>
@@ -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;
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductActions } from './product-actions';
describe('ProductActions', () => {
let component: ProductActions;
let fixture: ComponentFixture<ProductActions>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductActions]
})
.compileComponents();
fixture = TestBed.createComponent(ProductActions);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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<string>();
readonly options = input.required<ProductOptions>();
readonly added = output<void>();
readonly selectedColorCode = linkedSignal(() => this.options().colors[0]?.code);
readonly selectedStorageCode = linkedSignal(() => this.options().storages[0]?.code);
readonly adding = signal(false);
async onAddToCart(): Promise<void> {
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);
}
}
}
@@ -0,0 +1,8 @@
<dl class="product-description">
@for (row of rows(); track row.label) {
<div class="product-description__row">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
}
</dl>
@@ -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;
}
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductDescription } from './product-description';
describe('ProductDescription', () => {
let component: ProductDescription;
let fixture: ComponentFixture<ProductDescription>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductDescription]
})
.compileComponents();
fixture = TestBed.createComponent(ProductDescription);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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<ProductDetail>();
readonly rows = computed<DescriptionRow[]>(() => {
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 !== '');
});
}
@@ -0,0 +1,24 @@
<section class="product-detail">
<a mat-button routerLink="/" class="product-detail__back">
<mat-icon>arrow_back</mat-icon>
Go back to the list
</a>
@if (loading()) {
<div class="product-detail__loading">
<mat-progress-spinner mode="indeterminate" diameter="48"></mat-progress-spinner>
</div>
} @else if (error()) {
<p class="product-detail__error">{{ error() }}</p>
} @else if (product(); as product) {
<div class="product-detail__columns">
<app-product-image [imgUrl]="product.imgUrl" [alt]="product.brand + ' ' + product.model" />
<div class="product-detail__info">
<h1>{{ product.brand }} {{ product.model }}</h1>
<app-product-description [product]="product" />
<app-product-actions [productId]="product.id" [options]="product.options" />
</div>
</div>
}
</section>
@@ -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;
}
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductDetail } from './product-detail';
describe('ProductDetail', () => {
let component: ProductDetail;
let fixture: ComponentFixture<ProductDetail>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductDetail]
})
.compileComponents();
fixture = TestBed.createComponent(ProductDetail);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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<string>();
// product detail
readonly product = signal<ProductDetailModel | null>(null);
readonly loading = signal(true);
readonly error = signal<string | null>(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<void> {
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);
}
}
}
}
@@ -0,0 +1 @@
<img class="product-image" [src]="imgUrl()" [alt]="alt()" />
@@ -0,0 +1,8 @@
.product-image {
display: block;
width: 100%;
max-width: 360px;
aspect-ratio: 1 / 1;
object-fit: contain;
margin: 0 auto;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductImage } from './product-image';
describe('ProductImage', () => {
let component: ProductImage;
let fixture: ComponentFixture<ProductImage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductImage]
})
.compileComponents();
fixture = TestBed.createComponent(ProductImage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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<string>();
readonly alt = input.required<string>();
}
@@ -0,0 +1,19 @@
<mat-card
class="product-item"
tabindex="0"
role="button"
[attr.aria-label]="product().brand + ' ' + product().model"
(click)="onSelect()"
(keyup.enter)="onSelect()"
>
<img
class="product-item__image"
[src]="product().imgUrl"
[alt]="product().brand + ' ' + product().model"
/>
<mat-card-content>
<p class="product-item__brand">{{ product().brand }}</p>
<p class="product-item__model">{{ product().model }}</p>
<p class="product-item__price">{{ product().price }} &euro;</p>
</mat-card-content>
</mat-card>
@@ -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;
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductItem } from './product-item';
describe('ProductItem', () => {
let component: ProductItem;
let fixture: ComponentFixture<ProductItem>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductItem]
})
.compileComponents();
fixture = TestBed.createComponent(ProductItem);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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<Product>();
// Event emited when product is selected.
readonly productSelected = output<Product>();
onSelect(): void {
this.productSelected.emit(this.product());
}
}
@@ -0,0 +1,27 @@
<section class="product-list">
<header class="product-list__toolbar">
<h1>Product list</h1>
<app-product-search (searchChange)="onSearchTermChange($event)"></app-product-search>
</header>
@if (loading()) {
<div class="product-list__loading">
<mat-progress-spinner mode="indeterminate" diameter="48"></mat-progress-spinner>
</div>
} @else if (error()) {
<p class="product-list__error">{{ error() }}</p>
} @else if (filteredProducts().length === 0) {
<p class="product-list__empty">No products found.</p>
} @else {
<div class="product-list__scroll" #scrollContainer (scroll)="onScroll($event)">
<div class="product-list__grid">
@for (product of visibleProducts(); track product.id) {
<app-product-item
[product]="product"
(productSelected)="onSelectProduct($event)"
></app-product-item>
}
</div>
</div>
}
</section>
@@ -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;
}
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductList } from './product-list';
describe('ProductList', () => {
let component: ProductList;
let fixture: ComponentFixture<ProductList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductList]
})
.compileComponents();
fixture = TestBed.createComponent(ProductList);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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<ElementRef<HTMLElement>>('scrollContainer');
// raw list of items retrieved by api call
private readonly allProducts = signal<Product[]>([]);
// search tearm on the search input
readonly searchTerm = signal('');
readonly loading = signal(true);
readonly error = signal<string | null>(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<void> {
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)
);
}
}
@@ -0,0 +1,5 @@
<mat-form-field appearance="outline" class="product-search" subscriptSizing="dynamic">
<mat-label>Find by brand or model</mat-label>
<input matInput type="text" (input)="onInput($event)" placeholder="Samsung, iPhone 12..." />
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
@@ -0,0 +1,4 @@
.product-search {
width: 100%;
max-width: 320px;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductSearch } from './product-search';
describe('ProductSearch', () => {
let component: ProductSearch;
let fixture: ComponentFixture<ProductSearch>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductSearch]
})
.compileComponents();
fixture = TestBed.createComponent(ProductSearch);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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<string>();
// 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);
}
}