A full-stack, tested & responsive e-commerce site to browse and buy gifts for any occasion. Gifter is built with TypeScript, JavaScript, React, Redux, Sass, Stripe and Firebase. Users can browse from 100 gifts across 5 categories, add, edit and remove items from their cart, and checkout and pay (with test card details). Gifter works on any device size, and has been tested to minimise bugs (22 tests across 7 test suites, and 4 snapshots).
- Front End:
- JavaScript & Typescript
- React (Hooks: useState, useEffect, useContext, useReducer, useCallback)
- Redux (including Redux Thunk & Redux Saga for asynchronous redux side effect handling)
- Functional Programming Design Patterns: Currying, Memoisation (via Redux's Reselect library)
- Sass (BEM)
- Back End:
- Authentication: Firebase
- Server & Storage: Firestore
- Serverless Functions
- Payment Gateway: Stripe
- DevOps:
- Deployment: Netlify
- Testing
- Testing Library (jest-dom, React, user-event)
- Jest (& Snapshot testing for static/stateless components)
- (Enzyme: will attempt to convert to enzyme once React 18 is supported)
- Yarn
I also created a spin-off version of Gifter that leverages GraphQL and Apollo.
- Display of 5 gift categories (Birthday, Chirstmas, Thank you, Anniversary and Wedding)
- Authentication by email and password, or with Google
- Add/Remove item(s) to/from basket with a real time item counter and price total calculator
- Payment with Stripe
- Fully responsive for any device size
This project went though a few refactors and improvements as I learnt new libraries, frameworks and languages to incorporate. Using git tag -a <version> -m "<version comments>"
to mark each of these in the code history (see all tags), the state of Gifter at each milestone was as follows:
- Testing (React Testing Library / Jest / Snapshot Testing)
- Performance optimisations (useCallback and React memo for function and function output memoisations respectively, and code splitting (the bundle.js) with dynamic imports via React Lazy & React Suspense)
- Tightening Firebase (Firestore) security rules to read-only for all documents and categories, and allowing write access for users if the id matches the request's.
- Codebase converted from JavaScript to TypeScript, including React Components, the entire Redux Store (and Sagas), and utility files (for firebase and reducer)
- Redux (Redux Saga & Generator functions) and Stripe integration
- Serverless Function that creates a payment intent for Stripe. It is hosted on Netlify and uses AWS' Lambda function under the hood. This will help automate any necessary scaling.
- Currying & Memoisation Design Patterns (via Redux's Reselect library)
- Session Storage via Redux Persist to retain data between refreshes/sessions.
- UX: Users can now pay for their selected gifts using a test credit card number, which will be handled by Stripe.
- Fully working and responsive app in web, tablet and mobile, powered by JavaScript and React, including useContext and useReducer Hooks.
- Styling done in pure Sass (without the help of any frameworks) using the BEM methodology.
- Server, Storage and Authentication handled by Firebase (& Firestore).
- UX: Users can browse gifts across 5 categories, sign in (with email or via Google) and sign out, as well as add to, edit and remove items from their cart; they can also check out, but can't yet pay.
View tests
import * as cartReducers from '../store/cart/cart.reducer';
import * as cartTypes from '../store/cart/cart.types';
const mockCartItem = {
id: 35,
name: 'Smart Watch - Track your steps, calories, sleep and more',
imageUrl:
'https://images.unsplash.com/photo-1508685096489-7aacd43bd3b1?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8c21hcnQlMjB3YXRjaHxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=800&q=60',
price: 105
};
test('Cart reducer returns correct initial state', () => {
expect(cartReducers.cartReducer(undefined, {})).toEqual(cartReducers.CART_INITIAL_STATE);
});
test('Cart reducer sets cart items correctly', () => {
expect(
cartReducers.cartReducer(cartReducers.CART_INITIAL_STATE, {
type: cartTypes.CART_ACTION_TYPES.SET_CART_ITEMS,
payload: mockCartItem
}).cartItems
).toEqual(mockCartItem);
expect(
cartReducers.cartReducer(cartReducers.CART_INITIAL_STATE, {
type: cartTypes.CART_ACTION_TYPES.SET_CART_ITEMS,
payload: mockCartItem
}).isCartOpen
).toEqual(false);
});
View rule
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read;
}
match /users/{userId} {
allow read, get, create;
allow write: if request.auth != null && request.auth.id == userId;
}
match /categories/{category} {
allow read;
}
}
}
View Code
// $src/App.js
const Home = lazy(() => import('./components/Home'));
const Navbar = lazy(() => import('./components/Navbar'));
const About = lazy(() => import('./components/About'));
const Shop = lazy(() => import('./components/Shop'));
const SignIn = lazy(() => import('./components/auth/SignIn'));
const Checkout = lazy(() => import('./components/checkout/Checkout'));
const App = () => {
...
return (
<Suspense fallback={<Loader />}>
<Routes>
<Route path='/' element={<Navbar />}>
<Route index element={<Home />} />
<Route path='shop/*' element={<Shop />} />
<Route path='about' element={<About />} />
<Route path='auth' element={<SignIn />} />
<Route path='checkout' element={<Checkout />} />
</Route>
</Routes>
</Suspense>
);
};
View Code (Go To Checkout callback)
const goToCheckout = useCallback(() => {
if (cartItems.length > 0) {
navigate('/checkout');
dispatch(setIsCartOpen(!isCartOpen));
}
}, [isCartOpen]);
View Code (Redirecting to Target Category callback)
const redirectToCategory = useCallback((category: string) => {
navigate(`/shop/${category}`);
}, []);
Shop Data in TypeScript
const shopData: {
title: String;
items: {
id: Number;
name: String;
imageUrl: String;
price: Number;
}[];
}[] = [
{
title: 'Christmas',
items: [
{
id: 1,
name: '4-Piece Stocking',
imageUrl:
'https://images.unsplash.com/photo-1607900177462-ac553f1f5d97?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MXx8Y2hyaXN0bWFzJTIwc3RvY2tpbmdzfGVufDB8fDB8fA%3D%3D&auto=format&fit=crop&w=800&q=60',
price: 12
},
...
]
...
}
...
]
Cart Reducer in TypeScript
import { AnyAction } from 'redux';
import { setCartItems, setIsCartOpen } from './cart.action';
import { CartItem } from './cart.types';
export type CartState = {
readonly isCartOpen: boolean;
readonly cartItems: CartItem[];
};
export const CART_INITIAL_STATE: CartState = {
isCartOpen: false,
cartItems: []
};
export const cartReducer = (state = CART_INITIAL_STATE, action: AnyAction): CartState => {
if (setIsCartOpen.match(action)) {
return {
...state,
isCartOpen: action.payload
};
} else if (setCartItems.match(action)) {
return {
...state,
cartItems: action.payload
};
} else {
return state;
}
};
Category Types in TypeScript
export enum CATEGORIES_ACTION_TYPES {
FETCH_CATEGORIES_START = 'category/FETCH_CATEGORIES_START',
FETCH_CATEGORIES_SUCCESS = 'category/FETCH_CATEGORIES_SUCCESS',
FETCH_CATEGORIES_FAILURE = 'category/FETCH_CATEGORIES_FAILURE'
}
export type CategoryItem = {
id: number;
imageUrl: string;
name: string;
price: number;
};
export type Category = {
title: string;
imageUrl: string;
items: CategoryItem[];
};
export type CategoryMap = {
[key: string]: CategoryItem[];
};
View Code (Root Saga)
import { all, call } from 'redux-saga/effects';
import { categoriesSaga } from './categories/category.saga';
import { userSaga } from './user/user.saga';
// generator function
export function* rootSaga() {
yield all([call(categoriesSaga), call(userSaga)]);
}
View Code (Category Saga)
import { takeLatest, all, call, put } from 'redux-saga/effects';
import { getCategoriesAndDocuments } from '../../firebase/firebase.utils';
import { fetchCategoriesSuccess, fetchCategoriesFailure } from './category.action';
import { CATEGORIES_ACTION_TYPES } from './category.types';
// Generators:
export function* fetchCategoriesAsync() {
try {
// use `call` to turn it into an effect
const categoryArray = yield call(getCategoriesAndDocuments, 'categories'); // callable method & its params
yield put(fetchCategoriesSuccess(categoryArray)); // put is the dispatch inside a generator
} catch (err) {
// console.log(`ERROR: ${err}`);
yield put(fetchCategoriesFailure(err));
}
}
export function* onFetchCategories() {
// if many actions received, take the latest one
yield takeLatest(CATEGORIES_ACTION_TYPES.FETCH_CATEGORIES_START, fetchCategoriesAsync);
}
export function* categoriesSaga() {
yield all([call(onFetchCategories)]); // this will pause execution of the below until it finishes
}
View Code
import { CATEGORIES_ACTION_TYPES } from './category.types';
import { getCategoriesAndDocuments } from '../../firebase/firebase.utils';
export const fetchCategoriesStart = () => {
return { type: CATEGORIES_ACTION_TYPES.FETCH_CATEGORIES_START };
};
export const fetchCategoriesSuccess = (categories) => {
return { type: CATEGORIES_ACTION_TYPES.FETCH_CATEGORIES_SUCCESS, payload: categories };
};
export const fetchCategoriesFailure = (error) => {
return { type: CATEGORIES_ACTION_TYPES.FETCH_CATEGORIES_FAILURE, payload: error };
};
// Thunk:
export const fetchCategoriesAsync = () => async (dispatch) => {
dispatch(fetchCategoriesStart());
try {
const categoryArray = await getCategoriesAndDocuments('categories');
dispatch(fetchCategoriesSuccess(categoryArray));
} catch (error) {
// console.log(`ERROR: ${error}`);
dispatch(fetchCategoriesFailure(error));
}
};
React Context: useContext hook and CartContext and UserContext in the Navbar β later refactored to Redux.
View Code (Navbar)
// $src/components/Navbar.jsx
import { useContext } from 'react';
import { UserContext } from '../contexts/user.context';
import { CartContext } from '../contexts/cart.context';
const Navbar = () => {
const { currentUser } = useContext(UserContext);
const { isCartOpen, setIsCartOpen } = useContext(CartContext);
const toggleShowHideCart = () => setIsCartOpen(!isCartOpen);
const location = useLocation();
const hideCartWhenNavigatingAway = () => {
if (isCartOpen) {
setIsCartOpen(!isCartOpen);
}
};
...
}
View Code (User Context)
// $src/contexts/user.context.jsx
export const UserContext = createContext({
currentUser: null,
setCurrentUser: () => null
});
export const UserProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
const value = { currentUser, setCurrentUser };
useEffect(() => {
const unsubscribe = onAuthStateChangeListener((user) => {
if (user) {
createUserDocumentFromAuth(user);
}
setCurrentUser(user);
});
return unsubscribe;
}, []);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
};
View Code (Cart Context)
// $src/contexts/cart.context.jsx
export const CartContext = createContext({
isCartOpen: false,
setIsCartOpen: () => {},
cartItems: [],
addItemToCart: () => {},
removeItemFromCart: () => {},
reduceItemQuantityInCart: () => {},
getCartItemCount: () => {},
getCartTotalPrice: () => {}
});
export const CartProvider = ({ children }) => {
const [isCartOpen, setIsCartOpen] = useState(false);
const [cartItems, setCartItems] = useState([]);
const addItemToCart = (productToAdd) => {
const matchingItemIndex = cartItems.findIndex((item) => item.id === productToAdd.id);
if (matchingItemIndex === -1) {
setCartItems([...cartItems, { ...productToAdd, quantity: 1 }]);
} else {
const updatedCartItems = cartItems.map((item) => {
return item.id === productToAdd.id ? { ...item, quantity: item.quantity + 1 } : item;
});
setCartItems(updatedCartItems);
}
};
const removeItemFromCart = (productToRemove) => {
const updatedCartItems = cartItems.filter((item) => item.id !== productToRemove.id);
setCartItems(updatedCartItems);
};
const reduceItemQuantityInCart = (productToReduce) => {
const quantityOfItem = productToReduce.quantity;
const reduceQuantity = cartItems.map((item) => {
return item.id === productToReduce.id ? { ...item, quantity: item.quantity - 1 } : item;
});
const removeItem = cartItems.filter((item) => item.id !== productToReduce.id);
setCartItems(quantityOfItem > 1 ? reduceQuantity : removeItem);
};
const getCartItemCount = () => {
return cartItems.reduce((prev, curr) => prev + curr.quantity, 0);
};
const getCartTotalPrice = () => {
const total = cartItems.reduce((prev, curr) => prev + curr.price * curr.quantity, 0);
return total % 1 > 0 ? total.toFixed(2) : total; // currency rounding:
};
...
}
- Biggest challenge: Redux Saga (a lot of boilerplate set up and config to learn)
- TypeScript for Redux
- First time integrating a payment gateway
- Design (horizontal scroll with fade out effects on the side) on Shop Overview page
- In testing,
waitFor
(or other React Testing Library (RTL) async utilities such aswaitForElementToBeRemoved
orfindBy
) may be better practice than wrapping renders withact()
because:- RTL already wraps utilities in
act()
act()
will supress the warnings and make the test past but cause other issues
- RTL already wraps utilities in