feat: implement product detail and list components with search functionality
This commit is contained in:
@@ -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
@@ -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 }} €</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user