Shopping Cart
A complete shopping cart implementation with add/remove items, quantity updates, and dynamic total calculations. Demonstrates reactive data management and user interaction patterns.
Features Demonstrated
Array manipulation - Add, remove, update cart items
Lambda expressions - Complex calculations directly in templates using
reduce,filter, etc.Event handling - Button clicks, quantity changes
Conditional rendering - Empty cart state, checkout button
List rendering with keys - Efficient cart item updates
Two-way binding - Quantity inputs
Number formatting - Currency display
Component state management - Cart as a service
Code
View Model (shopping-cart.ts)
export interface CartItem {
id: number;
productId: number;
name: string;
price: number;
quantity: number;
image: string;
maxQuantity: number;
}
export class ShoppingCart {
cartItems: CartItem[] = [];
// Add item to cart
addToCart(product: { id: number; name: string; price: number; image: string; maxQuantity: number }) {
const existingItem = this.cartItems.find(item => item.productId === product.id);
if (existingItem) {
// Increase quantity if item already in cart
if (existingItem.quantity < existingItem.maxQuantity) {
existingItem.quantity++;
} else {
alert(`Maximum quantity (${existingItem.maxQuantity}) reached for ${existingItem.name}`);
}
} else {
// Add new item
this.cartItems.push({
id: Date.now(), // Simple ID generation
productId: product.id,
name: product.name,
price: product.price,
quantity: 1,
image: product.image,
maxQuantity: product.maxQuantity
});
}
}
// Update item quantity
updateQuantity(item: CartItem, newQuantity: number) {
if (newQuantity <= 0) {
this.removeItem(item);
} else if (newQuantity <= item.maxQuantity) {
item.quantity = newQuantity;
} else {
item.quantity = item.maxQuantity;
alert(`Maximum quantity is ${item.maxQuantity}`);
}
}
// Increase quantity
increaseQuantity(item: CartItem) {
if (item.quantity < item.maxQuantity) {
item.quantity++;
} else {
alert(`Maximum quantity (${item.maxQuantity}) reached`);
}
}
// Decrease quantity
decreaseQuantity(item: CartItem) {
if (item.quantity > 1) {
item.quantity--;
} else {
this.removeItem(item);
}
}
// Remove item from cart
removeItem(item: CartItem) {
const index = this.cartItems.indexOf(item);
if (index > -1) {
this.cartItems.splice(index, 1);
}
}
// Clear entire cart
clearCart() {
if (confirm('Are you sure you want to clear your cart?')) {
this.cartItems = [];
}
}
// Proceed to checkout
checkout() {
console.log('Proceeding to checkout with:', this.cartItems);
alert('Proceeding to checkout...');
// In a real app, navigate to checkout page or open checkout modal
}
}Currency Value Converter (currency-value-converter.ts)
import { valueConverter } from 'aurelia';
@valueConverter('currency')
export class CurrencyValueConverter {
toView(value: number, currencyCode = 'USD'): string {
if (value == null) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode
}).format(value);
}
}Template (shopping-cart.html)
<import from="./currency-value-converter"></import>
<div class="shopping-cart">
<!-- Cart Header -->
<header class="cart-header">
<h1>
Shopping Cart
<span class="item-count" if.bind="cartItems.length">
(${cartItems.reduce((sum, item) => sum + item.quantity, 0)} ${cartItems.reduce((sum, item) => sum + item.quantity, 0) === 1 ? 'item' : 'items'})
</span>
</h1>
<button
if.bind="cartItems.length"
click.trigger="clearCart()"
class="clear-btn">
Clear Cart
</button>
</header>
<!-- Empty Cart State -->
<div if.bind="!cartItems.length" class="empty-cart">
<div class="empty-icon">🛒</div>
<h2>Your cart is empty</h2>
<p>Add some products to get started!</p>
</div>
<!-- Cart Items -->
<div else class="cart-content">
<!-- Cart Items List -->
<div class="cart-items">
<div
repeat.for="item of cartItems; key: id"
class="cart-item">
<!-- Product Image -->
<div class="item-image">
<img src.bind="item.image" alt.bind="item.name">
</div>
<!-- Product Details -->
<div class="item-details">
<h3 class="item-name">${item.name}</h3>
<p class="item-price">${item.price | currency:'USD'} each</p>
<!-- Quantity Controls -->
<div class="quantity-controls">
<button
click.trigger="decreaseQuantity(item)"
class="qty-btn"
title="Decrease quantity">
−
</button>
<input
type="number"
value.bind="item.quantity"
min="1"
max.bind="item.maxQuantity"
change.trigger="updateQuantity(item, item.quantity)"
class="qty-input">
<button
click.trigger="increaseQuantity(item)"
class="qty-btn"
disabled.bind="item.quantity >= item.maxQuantity"
title="Increase quantity">
+
</button>
<span class="max-qty-label" if.bind="item.quantity >= item.maxQuantity">
(max)
</span>
</div>
</div>
<!-- Item Total and Remove -->
<div class="item-actions">
<div class="item-total">
${(item.price * item.quantity) | currency:'USD'}
</div>
<button
click.trigger="removeItem(item)"
class="remove-btn"
title="Remove item">
×
</button>
</div>
</div>
</div>
<!-- Cart Summary -->
<div class="cart-summary">
<h2>Order Summary</h2>
<div class="summary-row">
<span>Subtotal:</span>
<span>${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) | currency:'USD'}</span>
</div>
<div class="summary-row">
<span>Tax (8%):</span>
<span>${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * 0.08 | currency:'USD'}</span>
</div>
<div class="summary-row">
<span>Shipping:</span>
<span>
${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50
? 'FREE'
: (5.99 | currency:'USD')}
</span>
</div>
<div if.bind="cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) < 50 && cartItems.length" class="shipping-notice">
<small>💡 Add ${(50 - cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0)) | currency:'USD'} more for free shipping!</small>
</div>
<hr class="summary-divider">
<div class="summary-row total-row">
<strong>Total:</strong>
<strong class="total-amount">
${(cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) +
cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * 0.08 +
(cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50 ? 0 : 5.99)) | currency:'USD'}
</strong>
</div>
<button
click.trigger="checkout()"
disabled.bind="!cartItems.length"
class="checkout-btn">
Proceed to Checkout
</button>
</div>
</div>
</div>Styles (shopping-cart.css)
.shopping-cart {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.cart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.cart-header h1 {
font-size: 2rem;
color: #333;
margin: 0;
}
.item-count {
font-size: 1.2rem;
color: #666;
font-weight: normal;
}
.clear-btn {
padding: 0.5rem 1rem;
background: #fff;
border: 1px solid #dc3545;
color: #dc3545;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.clear-btn:hover {
background: #dc3545;
color: white;
}
.empty-cart {
text-align: center;
padding: 4rem 2rem;
}
.empty-icon {
font-size: 5rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-cart h2 {
color: #333;
margin-bottom: 0.5rem;
}
.empty-cart p {
color: #666;
}
.cart-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 2rem;
}
.cart-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.cart-item {
display: grid;
grid-template-columns: 100px 1fr auto;
gap: 1rem;
padding: 1rem;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.cart-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.item-image {
width: 100px;
height: 100px;
overflow: hidden;
border-radius: 4px;
background: #f5f5f5;
}
.item-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item-name {
margin: 0;
font-size: 1.1rem;
color: #333;
}
.item-price {
margin: 0;
color: #666;
font-size: 0.9rem;
}
.quantity-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.qty-btn {
width: 32px;
height: 32px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.qty-btn:hover:not(:disabled) {
background: #f0f0f0;
border-color: #007bff;
}
.qty-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.qty-input {
width: 60px;
height: 32px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
font-size: 1rem;
}
.max-qty-label {
font-size: 0.85rem;
color: #666;
}
.item-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.item-total {
font-size: 1.2rem;
font-weight: 600;
color: #007bff;
}
.remove-btn {
width: 32px;
height: 32px;
border: 1px solid #dc3545;
background: white;
color: #dc3545;
border-radius: 4px;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
transition: all 0.2s;
}
.remove-btn:hover {
background: #dc3545;
color: white;
}
.cart-summary {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
height: fit-content;
position: sticky;
top: 2rem;
}
.cart-summary h2 {
margin: 0 0 1rem 0;
font-size: 1.3rem;
color: #333;
}
.summary-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
color: #666;
}
.shipping-notice {
background: #e3f2fd;
padding: 0.5rem;
border-radius: 4px;
margin: 0.5rem 0;
text-align: center;
}
.shipping-notice small {
color: #1976d2;
}
.summary-divider {
border: none;
border-top: 1px solid #e0e0e0;
margin: 1rem 0;
}
.total-row {
font-size: 1.2rem;
color: #333;
margin-bottom: 1.5rem;
}
.total-amount {
color: #007bff;
font-size: 1.5rem;
}
.checkout-btn {
width: 100%;
padding: 1rem;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.checkout-btn:hover:not(:disabled) {
background: #218838;
}
.checkout-btn:disabled {
background: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 768px) {
.cart-content {
grid-template-columns: 1fr;
}
.cart-item {
grid-template-columns: 80px 1fr;
gap: 0.75rem;
}
.item-actions {
grid-column: 1 / -1;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.cart-summary {
position: static;
}
}How It Works
1. Lambda Expressions in Templates
Instead of computed properties in the view model, calculations are done directly in the template using lambda expressions:
<!-- Item count using reduce -->
${cartItems.reduce((sum, item) => sum + item.quantity, 0)}
<!-- Subtotal calculation -->
${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) | currency:'USD'}
<!-- Conditional logic for free shipping -->
${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50 ? 'FREE' : (5.99 | currency:'USD')}Aurelia's lambda expressions support complex operations like reduce, filter, map, every, and some directly in templates. The template automatically tracks dependencies and recalculates when cartItems changes.
2. Array Manipulation
Using array methods ensures change detection:
// ✓ Aurelia detects this
this.cartItems.splice(index, 1);
// ✓ Aurelia detects this
this.cartItems.push(newItem);
// ✗ Aurelia won't detect this
this.cartItems[index] = newItem; // Use splice instead3. Benefits of Lambda Expressions
Moving calculations to the template has several advantages:
Reduced boilerplate - No need for getter methods in the view model
Clear intent - Calculations are visible right where they're used
Single source of truth - The template directly expresses what data it needs
Automatic reactivity - Aurelia tracks all dependencies within lambda expressions
This approach is particularly useful for derived data that's only needed in the view.
4. Efficient List Updates
Using key: id allows Aurelia to track items efficiently:
<div repeat.for="item of cartItems; key: id">When items are removed or reordered, Aurelia reuses DOM elements.
5. Quantity Validation
Multiple ways to update quantity with validation:
updateQuantity(item: CartItem, newQuantity: number) {
if (newQuantity <= 0) {
this.removeItem(item);
} else if (newQuantity <= item.maxQuantity) {
item.quantity = newQuantity;
} else {
item.quantity = item.maxQuantity;
alert(`Maximum quantity is ${item.maxQuantity}`);
}
}6. Conditional Rendering
Show different UI based on cart state:
<div if.bind="!cartItems.length" class="empty-cart">
<!-- Empty state -->
</div>
<div else class="cart-content">
<!-- Cart items and summary -->
</div>Lambda expressions work seamlessly with conditionals: if.bind="cartItems.length" or if.bind="!cartItems.length".
7. Currency Formatting with Value Converter
The currency value converter formats prices consistently:
${product.price | currency:'USD'}The converter uses Intl.NumberFormat for proper currency formatting including the currency symbol, decimal places, and thousands separators. This keeps formatting logic out of the view model.
8. When to Use Lambda Expressions vs Computed Properties
Use lambda expressions in templates when:
The calculation is only needed in the view
The logic is straightforward and readable inline
You want to reduce view model boilerplate
Use computed properties in the view model when:
The calculation is complex and would make the template hard to read
The value is used in multiple places (template and view model logic)
You need to unit test the calculation logic
The calculation is expensive and you want explicit memoization
For this shopping cart example, the calculations are simple arithmetic operations that are only displayed to the user, making lambda expressions a great fit. They eliminate boilerplate while keeping the template clear and maintainable.
Variations
Persist Cart to LocalStorage
export class ShoppingCart {
cartItems: CartItem[] = [];
constructor() {
this.loadCart();
}
private loadCart() {
const saved = localStorage.getItem('cart');
if (saved) {
this.cartItems = JSON.parse(saved);
}
}
private saveCart() {
localStorage.setItem('cart', JSON.stringify(this.cartItems));
}
addToCart(product: any) {
// ... existing logic
this.saveCart();
}
removeItem(item: CartItem) {
// ... existing logic
this.saveCart();
}
}Add Discount Codes
export class ShoppingCart {
discountCode = '';
discountPercentage = 0;
applyDiscount() {
const codes: Record<string, number> = {
'SAVE10': 10,
'SAVE20': 20,
'FREESHIP': 0 // Handle free shipping separately
};
if (codes[this.discountCode.toUpperCase()]) {
this.discountPercentage = codes[this.discountCode.toUpperCase()];
} else {
alert('Invalid discount code');
}
}
}<div class="discount-section">
<input value.bind="discountCode" placeholder="Enter discount code">
<button click.trigger="applyDiscount()">Apply</button>
</div>
<!-- In the summary, calculate discount using lambda -->
<div class="summary-row" if.bind="discountPercentage > 0">
<span>Discount (${discountPercentage}%):</span>
<span>-${cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * (discountPercentage / 100) | currency:'USD'}</span>
</div>
<!-- Update total to include discount -->
<div class="summary-row total-row">
<strong>Total:</strong>
<strong class="total-amount">
${(cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * (1 - discountPercentage / 100) +
cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) * 0.08 +
(cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) >= 50 ? 0 : 5.99)) | currency:'USD'}
</strong>
</div>Cart as a Service
Make the cart available throughout the app:
// cart.service.ts
import { DI } from 'aurelia';
export const ICartService = DI.createInterface<ICartService>(
'ICartService',
x => x.singleton(CartService)
);
export interface ICartService extends CartService {}
export class CartService {
cartItems: CartItem[] = [];
// ... all cart methods
}
// Use in components
import { resolve } from 'aurelia';
import { ICartService } from './cart.service';
export class ProductList {
private readonly cart = resolve(ICartService);
addToCart(product: Product) {
this.cart.addToCart(product);
}
}Related
Last updated
Was this helpful?