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