Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/emilydaykin/gifter
🎁 A full-stack, tested & responsive e-commerce site to browse and buy gifts for any occasion
https://github.com/emilydaykin/gifter
firebase firestore-database functional-programming jest react react-testing-library redux snapshot-testing stripe
Last synced: 7 days ago
JSON representation
🎁 A full-stack, tested & responsive e-commerce site to browse and buy gifts for any occasion
- Host: GitHub
- URL: https://github.com/emilydaykin/gifter
- Owner: emilydaykin
- Created: 2022-06-23T09:47:57.000Z (over 2 years ago)
- Default Branch: main
- Last Pushed: 2022-08-09T15:31:51.000Z (over 2 years ago)
- Last Synced: 2024-09-09T17:20:23.605Z (2 months ago)
- Topics: firebase, firestore-database, functional-programming, jest, react, react-testing-library, redux, snapshot-testing, stripe
- Language: TypeScript
- Homepage:
- Size: 47.2 MB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# Gifter
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**).
[Live Gifter App](https://giftsbygifter.netlify.app/)
## Application Walkthrough
#### Home Page
#### About Page
#### Shop Overview (desktop & mobile)
#### Authentication Page (desktop & mobile)
#### Shop Category Page
#### Checkout (desktop & mobile)
#### Payment
## Tech Stack
- 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)
- YarnI also created a [spin-off version of Gifter](https://github.com/emilydaykin/graphql) that leverages GraphQL and Apollo.
## Features:
- 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## Milestones:
This project went though a few refactors and improvements as I learnt new libraries, frameworks and languages to incorporate. Using `git tag -a -m ""` to mark each of these in the code history ([see all tags](https://github.com/emilydaykin/Gifter/tags)), the state of Gifter at each milestone was as follows:#### (v6: Coming soon - Gifter to be a PWA (progressive web app))
### [v5](https://github.com/emilydaykin/Gifter/releases/tag/v5)
- Testing (React Testing Library / Jest / Snapshot Testing)
### [v4](https://github.com/emilydaykin/Gifter/releases/tag/v4)
- 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.
### [v3](https://github.com/emilydaykin/Gifter/releases/tag/v3)
- Codebase converted from JavaScript to TypeScript, including React Components, the entire Redux Store (and Sagas), and utility files (for firebase and reducer)
### [v2](https://github.com/emilydaykin/Gifter/releases/tag/v2)
- 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.
### [v1](https://github.com/emilydaykin/Gifter/releases/tag/v1)
- 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.## Tests:
## Code Snippets:
### Testing Reducers
View tests
```javascript
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);
});
```### Improving Firebase (Firestore) Security Rules
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;
}
}
}
```### Dynamic Imports via React Lazy and React Suspense for performance optimisation
View Code
```javascript
// $src/App.jsconst 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 (
}>
}>
} />
} />
} />
} />
} />
);
};
```### UseCallback hook to optimise performance by memoising functions
View Code (Go To Checkout callback)
```javascript
const goToCheckout = useCallback(() => {
if (cartItems.length > 0) {
navigate('/checkout');
dispatch(setIsCartOpen(!isCartOpen));
}
}, [isCartOpen]);
```View Code (Redirecting to Target Category callback)
```javascript
const redirectToCategory = useCallback((category: string) => {
navigate(`/shop/${category}`);
}, []);
```### TypeScript
Shop Data in TypeScript
```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
```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
```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[];
};```
### Generator Functions & Redux Saga for Categories
View Code (Root Saga)
```javascript
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)
```javascript
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
}
```### Redux Thunk for Categories
View Code
```javascript
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)
```javascript
// $src/components/Navbar.jsximport { 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)
```javascript
// $src/contexts/user.context.jsxexport 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 {children};
};
```View Code (Cart Context)
```javascript
// $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:
};
...
}
```## Challenges, Wins & Key Learning
### Challenges:
- Biggest challenge: Redux Saga (a lot of boilerplate set up and config to learn)
- TypeScript for Redux### Wins
- First time integrating a payment gateway
- Design (horizontal scroll with fade out effects on the side) on Shop Overview page### Key Learnings:
- In testing, `waitFor` (or other React Testing Library (RTL) async utilities such as `waitForElementToBeRemoved` or `findBy`) may be better practice than wrapping renders with `act()` because:
1. RTL already wraps utilities in `act()`
2. `act()` will supress the warnings and make the test past but cause other issues