Giter Club home page Giter Club logo

gifter's Introduction

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

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)
    • Yarn

I also created a spin-off version of Gifter 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 <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:

(v6: Coming soon - Gifter to be a PWA (progressive web app))

  • 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.

Tests:

โ€ƒ

Code Snippets:

Testing Reducers

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);
});

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
// $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>
  );
};

UseCallback hook to optimise performance by memoising functions

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}`);
}, []);

TypeScript

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[];
};

Generator Functions & Redux Saga for Categories

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
}

Redux Thunk for Categories

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:
  };
  ...
}

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

gifter's People

Contributors

emilydaykin avatar

Watchers

 avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.