Shopping and food delivery carts are a well-resourced topic but what if we take it a little further and create a shared cart for an office community or group of friends that will be useable, clear, and secure. This would take a load of at least one human in that group.
Let me tell you how it goes sometimes in our office. Every now and then we need to make a group order of something or sign up for a corporate event with a choice of food. We create a shared Google Doc and a clusterf**k of anonymous animals starts an onslaught.
If there was a service you could attach to any business and automate the complicated order and payment procedures, it could be useful. This is how you build such a service using Angular and Firebase
Project source code on GitHub.
Setting up a project
1. Add a GitHub repository, come up with a name, description, specify the type, the license, and the .gitignore file. I called it FoodOrder:
- Then, clone the repository. For this, enter the following command in terminal:
git clone git@github.com:denismaster/FoodOrder.git
- Change the repository address on yours:
- Then, navigate to the created catalog:
cd FoodOrder
- Install Angular CLI and create a new Angular application with it:
npm install -g @angular/cli
- Create a new project:
ng new FoodOrder --directory. --force
We are using the –directory option to create a project in the existing directory and the –force option to rewrite the README and .gitignore files.
- Open the project in an editor. I use Visual Studio Code:
- For design, we are using the Bootstrap 4.0 library. Install it with the following command:
npm install bootstrap
Developing the menu
We’ll group the menu items into categories with different titles, prices, size in grams, and all sorts of additional details. Each category is defined by the title and the list of items.
- Create a models.ts file with the contents from here.
The Product class contains basic information about the product. ProductCategory describes the category of the product. The final menu is just a ProductCategory[] massive.
- Add some items. Create a new service, let’s call it MenuService with a list of our products from here:
In our case, the service returns the list of predefined objects but it can be changed at any given time, for example, for it to make a backend request.
- Add components to display the menu, categories, and products. For this, use the following folder structure:
The MenuComponent component displays the menu as a whole. This component is simple and is there just to show the complete list of items. Use the HTML code from here. And the Typescript from here.
- Create the MenuCategoryComponent component. It is used to display the categories. Create a card with the category name and the list of products as a list of MenuCategoryItemComponent components. Use the HTML code for MenuCategoryComponent from here. And the Typescript from here.
- Create the MenuCategoryItemComponent component. It is used to display the product itself, as well as the UI connected to it. In this component, we react to a click and display additional interface items for portion sizes.
6. You’ll need an additional UpdateOrderAction class:
export interface UpdateOrderAction {
action: "add" | "remove"
name: string;
category?: string;
price: number;
}
This class describes the changes that happen to the order itself. Use the HTML code for MenuCategoryItemComponent from here. And the Typescript from here.
- Collect the entire code. For that, create the HomeComponent component. At this stage, it will only display MenuComponent:
<app-menu [menu]="menu$ | async"></app-menu>
And the following Typescript:
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
})
export class HomeComponent implements OnInit {
menu$: Observable<ProductCategory[]>;
constructor(private menuService: MenuService) {
}
ngOnInit() {
this.menu$ = this.menuService.getMenu().pipe(take(1));
}
}
Here’s what the menu looks like at this point. Everything works fine but there is no total cost calculation and no sync. To implement those, we’ll need Firebase.
Integrating Firebase
As a backend, we’ll be using Google Firebase.
We use Cloud FireStore to store the data. This is a NoSQL document-oriented database which uses collections and documents inside of them. The collections can be attached to different documents.
Each table allows sending and changing data to all the subscribers in real time. This creates an instant update for all the clients. For this, use the menu we’ve developed.
Install the AngularFire2 library from here.
npm install firebase @angular/fire --save
Add new models to models.ts to describe the order.
export interface OrderItem {
id: string,
name: string;
category: string,
price: number
}
export interface Order {
dishes: OrderItem[]
}
Inside the Firestore, we’ll be storing the order as an object with the dishes property as an OrderItem[] massive. Inside this massive, we store the menu item name required to delete the order.
- Create a new OrderService service. We’ll be using it for the Firebase interaction. Creating an order:
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
import { Router } from '@angular/router';
import { AngularFirestore } from '@angular/fire/firestore';
import { v4 as uuid } from 'uuid';
import { Order } from './models';
@Injectable({
providedIn: 'root'
})
export class OrderService {
constructor(private router: Router, private afs: AngularFirestore, ) {
}
createOrder() {
let orderId = uuid();
sessionStorage.setItem('current_order', orderId)
this.afs.collection("orders").doc<Order>(orderId).set({ dishes: [] })
.then(_ => {
this.router.navigate(['/'], { queryParams: { 'order': orderId } });
})
.catch(err => {
alert(err);
})
}
}
2. To check the order code, use ActivatedRoute. We also save the existing order code value in the SessionStorage to easily recover it when reloading the page.
import { Component, OnInit } from '@angular/core';
import { ProductCategory } from '../models';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators'
import { MenuService } from '../menu/menu.service';
import { ActivatedRoute } from '@angular/router';
import { OrderService } from '../order.service';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
})
export class HomeComponent implements OnInit {
menu$: Observable<ProductCategory[]>;
orderId: string;
constructor(private activatedRoute: ActivatedRoute, private menuService: MenuService, private orderService: OrderService) { }
ngOnInit() {
const order = this.activatedRoute.snapshot.queryParams.order;
if (!order) {
this.orderService.createOrder();
this.orderId = sessionStorage.getItem('current_order');
}
else {
this.orderId = order;
sessionStorage.setItem('current_order', order);
}
this.menu$ = this.menuService.getMenu().pipe(take(1));
}
}
- Create a special component to display the selected items (the cart). Let’s call it OrderStatusComponent:
<div class="card mb-3">
<div class="card-body">
<div *ngFor="let category of dishCategories">
<strong>{{category}}:</strong>
<span *ngFor="let dish of categorizedDishes[category]">
<span *ngIf="dish.amount>1">
{{dish.name}} x {{dish.amount}}
</span>
<span *ngIf="dish.amount==1">
{{dish.name}}
</span>
</span>
</div>
<div>
<strong>Total: {{ totalPrice}} ₽</strong>
</div>
</div>
</div>
import { Order } from '../models';
import { Input, Component } from '@angular/core';
@Component({
selector: 'app-order-status',
templateUrl: './order-status.component.html',
})
export class OrderStatusComponent{
@Input()
order: Order = null;
get dishes() {
if(!this.order || !this.order.dishes) return [];
return this.order.dishes;
}
get categorizedDishes() {
return this.dishes.reduce((prev, current) => {
if (!prev[current.category]) {
prev[current.category] = [{
name: current.name,
amount: 1
}]
}
else {
let productsInCategory: { name: string, amount: number }[] = prev[current.category];
let currentDishIndex = productsInCategory.findIndex(p => p.name == current.name)
if (currentDishIndex !== -1) {
productsInCategory[currentDishIndex].amount++;
}
else {
productsInCategory.push({
name: current.name,
amount: 1
});
}
}
return prev;
}, {});
}
get dishCategories() {
return Object.keys(this.categorizedDishes);
}
get totalPrice() {
return this.dishes.reduce((p, c) => {
return p + c.price
}, 0)
}
}
- Add the new getOrder() method in OrderService:
getOrder(orderId:string): Observable<Order> {
const orderDoc = this.afs.doc<Order>(`orders/${orderId}`);
return orderDoc.valueChanges();
}
With this method, we subscribe to the changes in the document in Firestore. This is how the data sync happens between different users who use the same order code.
5. Finally, add the updateOrder() method in OrderService, which will be updating the contents of the order in Firestore:
async updateOrder(orderId: string, update: UpdateOrderAction) {
const order = await this.afs.doc<Order>(`orders/${orderId}`).valueChanges().pipe(take(1)).toPromise();
if (update.action == "add") {
this.afs.doc<Order>(`orders/${orderId}`).update({
dishes: <any>firebase.firestore.FieldValue.arrayUnion({
id: uuid(),
name: update.name,
category: update.category,
price: update.price
})
})
}
else {
const dishIds = order.dishes.filter(d=>d.name==update.name).map(d=>d.id);
const idToRemove = dishIds[0];
if(!idToRemove) return;
this.afs.doc<Order>(`orders/${orderId}`).update({
dishes: <any>firebase.firestore.FieldValue.arrayRemove({
id: idToRemove,
name: update.name,
category: update.category,
price: update.price
})
})
}
}
Because of Firebase we also have a shareable cart between different users:
- Add the ability to clear the cart with this code in OrderService:
private _clear$ = new Subject<void>();
get clearOrder$() {
return this._clear$.asObservable();
}
clearOrder(orderId: string) {
this.afs.doc<Order>(`orders/${orderId}`).update({
dishes: []
})
this._clear$.next();
}
handleOrderClearing(){
this._clear$.next();
}
- Modify MenuCategoryItemComponent:
constructor(private orderService: OrderService) {
this.orderService.clearOrder$.subscribe(()=>{
this.amount=0;
sessionStorage.removeItem(`${this.orderId}-${this.product.name}`)
})
}
- Here’s how another user can clear the cart:
this.order$.pipe(filter(order => !!order)).subscribe(order => {
if (order.dishes && !order.dishes.length) {
this.orderService.handleOrderClearing();
}
})
That’s basically it. Now the website has shared access and all it takes to start working is implement the order feature.
Setting up CI/CD
To set up CI/CD, use TravisCI. This product is really simple and sustainable, especially in the GitHub integration. For the setup, you’ll need the .travis.yml file in the root of the repository:
This script assembles your Angular application and deploys the master branch in Firebase. For this, you’ll need the token which you receive with the following command:
firebase login:ci
An example build on TravisCI: