Skip to main content

Exercise 2: Component Communication & Services

Objective

Build a product catalog application with multiple communicating components and shared services.

Setup

ng new product-catalog --standalone --routing=false --style=css
cd product-catalog
ng serve

Requirements

Create a product catalog with:

  • Product list display
  • Product details view
  • Shopping cart functionality
  • Category filtering
  • Search functionality

Project Structure

src/app/
├── models/
│ └── product.model.ts
├── services/
│ ├── product.service.ts
│ └── cart.service.ts
├── components/
│ ├── product-list/
│ ├── product-card/
│ ├── product-details/
│ ├── cart/
│ └── search-bar/
└── app.component.ts

Step 1: Define Models

// src/app/models/product.model.ts
export interface Product {
id: number;
name: string;
description: string;
price: number;
category: string;
image: string;
inStock: boolean;
rating: number;
}

export interface CartItem {
product: Product;
quantity: number;
}

Step 2: Create Product Service

// src/app/services/product.service.ts
import { Injectable, signal } from '@angular/core';
import { Product } from '../models/product.model';

@Injectable({
providedIn: 'root',
})
export class ProductService {
private products = signal<Product[]>([
{
id: 1,
name: 'Wireless Headphones',
description: 'High-quality wireless headphones with noise cancellation',
price: 99.99,
category: 'Electronics',
image: 'https://via.placeholder.com/200',
inStock: true,
rating: 4.5,
},
{
id: 2,
name: 'Smart Watch',
description: 'Fitness tracking smartwatch with heart rate monitor',
price: 199.99,
category: 'Electronics',
image: 'https://via.placeholder.com/200',
inStock: true,
rating: 4.2,
},
{
id: 3,
name: 'Running Shoes',
description: 'Comfortable running shoes for daily training',
price: 79.99,
category: 'Sports',
image: 'https://via.placeholder.com/200',
inStock: false,
rating: 4.7,
},
{
id: 4,
name: 'Yoga Mat',
description: 'Non-slip yoga mat with carrying strap',
price: 29.99,
category: 'Sports',
image: 'https://via.placeholder.com/200',
inStock: true,
rating: 4.0,
},
]);

// Expose as readonly
readonly allProducts = this.products.asReadonly();

getProductById(id: number): Product | undefined {
return this.products().find(p => p.id === id);
}

getCategories(): string[] {
const categories = new Set(this.products().map(p => p.category));
return Array.from(categories);
}
}

Step 3: Create Cart Service

// src/app/services/cart.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { CartItem, Product } from '../models/product.model';

@Injectable({
providedIn: 'root',
})
export class CartService {
private items = signal<CartItem[]>([]);

// Computed values
readonly cartItems = this.items.asReadonly();

readonly totalItems = computed(() =>
this.items().reduce((sum, item) => sum + item.quantity, 0)
);

readonly totalPrice = computed(() =>
this.items().reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
)
);

// Actions
addToCart(product: Product) {
const existingItem = this.items().find(
item => item.product.id === product.id
);

if (existingItem) {
this.updateQuantity(product.id, existingItem.quantity + 1);
} else {
this.items.update(items => [...items, { product, quantity: 1 }]);
}
}

removeFromCart(productId: number) {
this.items.update(items =>
items.filter(item => item.product.id !== productId)
);
}

updateQuantity(productId: number, quantity: number) {
if (quantity <= 0) {
this.removeFromCart(productId);
return;
}

this.items.update(items =>
items.map(item =>
item.product.id === productId ? { ...item, quantity } : item
)
);
}

clearCart() {
this.items.set([]);
}
}

Step 4: Create Product Card Component

ng generate component components/product-card --standalone
// src/app/components/product-card/product-card.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Product } from '../../models/product.model';

@Component({
selector: 'app-product-card',
standalone: true,
imports: [CommonModule],
templateUrl: './product-card.component.html',
styleUrl: './product-card.component.css',
})
export class ProductCardComponent {
@Input({ required: true }) product!: Product;
@Output() addToCart = new EventEmitter<Product>();
@Output() viewDetails = new EventEmitter<number>();

onAddToCart() {
this.addToCart.emit(this.product);
}

onViewDetails() {
this.viewDetails.emit(this.product.id);
}
}
<!-- src/app/components/product-card/product-card.component.html -->
<div class="product-card">
<div class="product-image">
<img [src]="product.image" [alt]="product.name" />
@if (!product.inStock) {
<span class="out-of-stock-badge">Out of Stock</span>
}
</div>

<div class="product-info">
<span class="category">{{ product.category }}</span>
<h3>{{ product.name }}</h3>
<p class="description">{{ product.description }}</p>

<div class="rating">
@for (star of [1,2,3,4,5]; track star) {
<span [class.filled]="star <= product.rating"></span>
}
<span class="rating-value">{{ product.rating }}</span>
</div>

<div class="price-section">
<span class="price">${{ product.price }}</span>
<button
(click)="onAddToCart()"
[disabled]="!product.inStock"
class="add-to-cart-btn"
>
{{ product.inStock ? 'Add to Cart' : 'Unavailable' }}
</button>
</div>

<button (click)="onViewDetails()" class="details-btn">View Details</button>
</div>
</div>

Step 5: Create Product List Component

ng generate component components/product-list --standalone
// src/app/components/product-list/product-list.component.ts
import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ProductCardComponent } from '../product-card/product-card.component';
import { ProductService } from '../../services/product.service';
import { CartService } from '../../services/cart.service';
import { Product } from '../../models/product.model';

@Component({
selector: 'app-product-list',
standalone: true,
imports: [CommonModule, FormsModule, ProductCardComponent],
templateUrl: './product-list.component.html',
styleUrl: './product-list.component.css',
})
export class ProductListComponent {
private productService = inject(ProductService);
private cartService = inject(CartService);

// State
selectedCategory = signal<string>('All');
searchQuery = signal<string>('');

// Data
allProducts = this.productService.allProducts;
categories = ['All', ...this.productService.getCategories()];

// Filtered products
filteredProducts = computed(() => {
let products = this.allProducts();

// Filter by category
if (this.selectedCategory() !== 'All') {
products = products.filter(p => p.category === this.selectedCategory());
}

// Filter by search query
const query = this.searchQuery().toLowerCase();
if (query) {
products = products.filter(
p =>
p.name.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query)
);
}

return products;
});

onAddToCart(product: Product) {
this.cartService.addToCart(product);
}

onViewDetails(productId: number) {
console.log('View details for product:', productId);
// Could navigate to details page or open modal
}

setCategory(category: string) {
this.selectedCategory.set(category);
}
}
<!-- src/app/components/product-list/product-list.component.html -->
<div class="product-list-container">
<!-- Search and Filters -->
<div class="controls">
<input
type="text"
[value]="searchQuery()"
(input)="searchQuery.set($any($event.target).value)"
placeholder="Search products..."
class="search-input"
/>

<div class="category-filters">
@for (category of categories; track category) {
<button
(click)="setCategory(category)"
[class.active]="selectedCategory() === category"
class="category-btn"
>
{{ category }}
</button>
}
</div>
</div>

<!-- Products Grid -->
<div class="products-grid">
@for (product of filteredProducts(); track product.id) {
<app-product-card
[product]="product"
(addToCart)="onAddToCart($event)"
(viewDetails)="onViewDetails($event)"
></app-product-card>
} @empty {
<div class="empty-state">
<p>No products found</p>
</div>
}
</div>
</div>

Step 6: Create Cart Component

ng generate component components/cart --standalone
// src/app/components/cart/cart.component.ts
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CartService } from '../../services/cart.service';

@Component({
selector: 'app-cart',
standalone: true,
imports: [CommonModule],
templateUrl: './cart.component.html',
styleUrl: './cart.component.css',
})
export class CartComponent {
cartService = inject(CartService);

updateQuantity(productId: number, quantity: number) {
this.cartService.updateQuantity(productId, quantity);
}

removeItem(productId: number) {
this.cartService.removeFromCart(productId);
}

clearCart() {
if (confirm('Clear all items from cart?')) {
this.cartService.clearCart();
}
}
}
<!-- src/app/components/cart/cart.component.html -->
<div class="cart">
<div class="cart-header">
<h2>Shopping Cart</h2>
<span class="item-count">{{ cartService.totalItems() }} items</span>
</div>

@if (cartService.cartItems().length > 0) {
<ul class="cart-items">
@for (item of cartService.cartItems(); track item.product.id) {
<li class="cart-item">
<img [src]="item.product.image" [alt]="item.product.name" />
<div class="item-details">
<h4>{{ item.product.name }}</h4>
<p class="item-price">${{ item.product.price }}</p>
</div>

<div class="quantity-controls">
<button
(click)="updateQuantity(item.product.id, item.quantity - 1)"
class="qty-btn"
>
-
</button>
<span class="quantity">{{ item.quantity }}</span>
<button
(click)="updateQuantity(item.product.id, item.quantity + 1)"
class="qty-btn"
>
+
</button>
</div>

<p class="item-total">
${{ (item.product.price * item.quantity).toFixed(2) }}
</p>

<button
(click)="removeItem(item.product.id)"
class="remove-btn"
aria-label="Remove item"
>
×
</button>
</li>
}
</ul>

<div class="cart-footer">
<div class="total">
<span>Total:</span>
<span class="total-price">
${{ cartService.totalPrice().toFixed(2) }}
</span>
</div>
<button (click)="clearCart()" class="clear-btn">Clear Cart</button>
<button class="checkout-btn">Checkout</button>
</div>
} @else {
<div class="empty-cart">
<p>Your cart is empty</p>
</div>
}
</div>

Step 7: Main App Component

// src/app/app.component.ts
import { Component } from '@angular/core';
import { ProductListComponent } from './components/product-list/product-list.component';
import { CartComponent } from './components/cart/cart.component';

@Component({
selector: 'app-root',
standalone: true,
imports: [ProductListComponent, CartComponent],
template: `
<div class="app">
<header class="app-header">
<h1>Product Catalog</h1>
</header>

<div class="app-content">
<main class="main-content">
<app-product-list></app-product-list>
</main>

<aside class="sidebar">
<app-cart></app-cart>
</aside>
</div>
</div>
`,
styles: [
`
.app {
min-height: 100vh;
background: #f5f5f5;
}

.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
text-align: center;
}

.app-content {
display: grid;
grid-template-columns: 1fr 350px;
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}

@media (max-width: 968px) {
.app-content {
grid-template-columns: 1fr;
}
}
`,
],
})
export class AppComponent {}

Bonus Challenges

Challenge 1: Add Product Details Modal

Create a modal that shows full product details when clicking "View Details"

Challenge 2: Add Sorting

Add sorting options (price low-to-high, high-to-low, rating, name)

Challenge 3: Add to Favorites

Implement a favorites/wishlist feature with its own service

Challenge 4: Persist Cart

Save cart to localStorage and restore on page load

Challenge 5: Add Animations

Add smooth animations for adding/removing items

Key Concepts Practiced

@Input and @Output decorators ✅ Service creation and injection ✅ Signal-based state management ✅ Computed values ✅ Component composition ✅ Event emission and handling ✅ Shared services across components

Time Estimate

  • Basic implementation: 3-4 hours
  • With styling: 4-5 hours
  • With bonus challenges: 6-8 hours

Next Steps

  • Experiment with different component communication patterns
  • Add more advanced features like pagination or infinite scroll