My Final Project: A Full Stack eComm Store

christinecontreras

Christine Contreras

Posted on February 6, 2022

My Final Project: A Full Stack eComm Store

This is my capstone project for Flatiron – the project where I put everything I’ve learned in the past 10 months. I currently work in eCommerce digital marketing and want to transition towards eCommerce development for the next step in my career. That is why I decided to build an e-commerce store as my final project. My eCommerce store is called Free Spirit Designs and is a small boutique jewelry eCommerce site.

What I Used In My Project

  • React framework for my front-end
  • React Router for my front-end routes
  • Material UI for styling
  • Ruby on Rails for my backend
  • ActiveRecord to handle my models and communication with my database
  • AWS to host images
  • Stripe for checkout

Project Planning

I spent about two weeks planning out my project before writing any code. I built wireframes for the front-end and wrote out my backend models, attributes, routes, and schema. It was really important to me to do this so that I had a clear understanding of where my project was going before coding. In the long run, it helped me develop even faster since I knew what I wanted it to look like, when and where to reuse components in React, and had a clear understanding of my model relationships in RoR (ruby on rails).

Frontend Planning

Mockups

Here are some side-by-side mockups vs the end product

Homepage

Free Spirit Designs Homepage mockup vs final

PLP (Category Page)

Free Spirit Designs PLP mockup vs final

PDP (Product Page)

Free Spirit Designs PDP mockup vs final

Cart

Free Spirit Designs cart mockup vs final

Admin Dashboard

Free Spirit Designs admin dashboard mockup vs final

Website Flows

Here are the three main flows I thought were important to understand. You can click on the links and walk through them if you like.

Admin Flow

View Flow Here

  • shows what the admin dashboard looks like when logged in
  • shows a view of all categories and how to create a new one
  • shows a view of all products and how to create a new product, SKU, and slot the product
  • shows all site orders
Profile Flow

View Flow Here

  • shows how to create a new user account
  • shows what a user's profile would look like if they were logged in.
  • shows a users profile info, shipping info, and order history
User Shopping Flow

View Flow Here

  • shows what a PLP (product listing page/category page) would look like
  • shows what a PDP (product display page) looks like
  • shows how the cart looks like
  • shows the checkout process

Backend Planning

This part took a lot of thought on my end and was re-worked a couple of times. What attributes should lie with the product vs a SKU was a big one I went back and forth on. In the end, I decided to only give the product title and description to the product model and all other attributes to the SKU model.

I also struggled with how the relationship between the user, cart, orders, and selected items should be handled. At first, I had the cart belonging to a user. But when I thought about it more, it didn’t really belong to a user—it belonged to a browser. If a visitor isn’t logged in, they can still have an active cart.

Initially, I had SKUs going directly into a cart and orders but decided to have a joint table called selected items instead. SKUs really only belong to products—they can’t belong to only one user. If they did, my backend would think all quantities of the SKU belonged to a user instead of just one of them. It also meant if someone bought a SKU, my backend would think it’s no longer available.

Backend Relationships and Attributes

Cart        User ---------------> Orders
|           :first_name           :user_id
|           :last_name            :amount
|           :email                :address
|           :password_digest      :status
|           :address              :session_id
|            |                    :invoice
|            |                    :email
|            |                    :name
|            |                      |
|            V                      |
---------> SelectedItem <------------
           :sku_id
           :order_id
           :cart_id
           :quantity
           ^
           |
           |
         SKU <------------- Product ------ > ProductCategory <---- Category
         :product_id        :title           :product_id           :name
         :size              :description     :category_id          :description
         :color                                                    :isActive
         :price
         :quantity

Enter fullscreen mode Exit fullscreen mode

My Routes

Rails.application.routes.draw do
 namespace :api do
   resources :users, only: [:destroy, :update]
   post "/signup", to: "users#create"
   get "/me", to: "users#show"

   post "/login", to: "sessions#create"
   delete "/logout", to: "sessions#destroy"


   get "/user-cart", to: "carts#show"
   resources :carts, only: [:update]
   patch "/carts/:id/delete-item", to: "carts#delete_item"
   patch "/carts/:id/update-item-qty/:quantity", to: "carts#update_item"

   resources :categories
   resources :products
   resources :skus, only: [:create, :update, :destroy]

   post "/update-product-categories", to: "product_categories#update_product_categories"

   resources :orders, only: [:index, :update]
   post "/checkout", to: "stripe#checkout"
   post "/order-success", to: "stripe#order_success"

   post "/presigned_url", to: "direct_upload#create"
  end

 get "*path", to: "fallback#index", constraints: ->(req) { !req.xhr? && req.format.html? }


end

Enter fullscreen mode Exit fullscreen mode

Project Learnings

This is an overview of the parts of my capstone project I struggled with or made thoughtful decisions on. I don’t discuss users and sessions in this blog, but if you would like more information on how I did this part (create new users, persistent login, etc.) I cover it in my previous Ruby On Rails blog post here.

Cart

I spent a good amount of time contemplating the creation of a cart. I decided on a custom route that is called as soon as my app loads. It looks to see if the cart session already exists in my browser. If it does, my backend sends back the cart info. If not, it creates a whole new cart and session.

I also created custom routes in the cart controller to handle updating and deleting items from the cart. I chose to run these actions in the cart controller instead of the selected items controller so that I could send the entire cart back to the front-end once the change was done.

#cart_controller.rb
class Api::CartsController < ApplicationController
   rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response
   before_action :set_cart, only: [:update, :delete_item, :update_item ]
   skip_before_action :authorize


   def show
       if session.include? :cart_id
           set_cart
       else
           create_cart
       end

       render json: @cart, include: ['selected_items', 'selected_items.sku']
   end

   def update
       sku = Sku.find_by(product_id: params[:product_id], color: params[:color], size: params[:size])

       if sku
           cartItem = @cart.selected_items.find_by(sku_id: sku.id)
           if cartItem #cart item already exists update the quantity
                newQuantity = params[:quantity] + cartItem.quantity
                cartItem.update(quantity: newQuantity)
           else  #create cart item
                newItem = @cart.selected_items.create(quantity: params[:quantity], sku_id: sku.id)
           end
       else
           render json: { errors: ['Sku Not Found'] }, status: :not_found
       end

       render json: @cart, include: ['selected_items', 'selected_items.sku'], status: :accepted

   end

   def delete_item
       item = set_selected_item
       item.destroy
       render json: @cart, include: ['selected_items', 'selected_items.sku'], status: :accepted
   end

   def update_item
       item = set_selected_item
       item.update(quantity: params[:quantity])
       render json: @cart, include: ['selected_items', 'selected_items.sku'], status: :accepted
   end

   private

   def set_cart
       @cart = Cart.find_by(id: session[:cart_id])
   end

   def set_selected_item
       @cart.selected_items.find_by(id: params[:selected_item_id])
   end

   def create_cart
       @cart = Cart.create
       session[:cart_id] = @cart.id
       @cart
   end

   def render_not_found_response
       render json: { errors: ['No Cart Found'] }, status: :not_found
   end
end

Enter fullscreen mode Exit fullscreen mode

Stripe Checkout

I thought Stripe checkout would be one of the hardest parts of my project but they have great documentation and an easy setup that made my checkout less work than I initially intended. I made two custom routes for the stripe checkout: one for creating a stripe order and another for fetching a stripe order to send back and create a new order in my database.

The only disadvantage I found using Stripe checkout is that you can’t pass user data in. So even though I had a user's address, name, and email address, I couldn’t pass it to Stripe’s pre-designed checkout. If I’d used Stripe elements, it would’ve been possible. But that depends on how much customization you would like in your checkout. It was more important to me that my checkout be secure and ADA compliant than passing in the user's info so I opted for Stripe’s pre-designed checkout.

Backend Implementation

#stripe_controller.rb

class Api::StripeController < ApplicationController
   before_action :set_stripe_key

   def checkout
       # change items to correct stripe format
       shipping_amount = params[:shipping].to_i * 100
       orderItems = params[:items].collect do |item|
           selected_item = SelectedItem.find_by(id: item)

           {
               price_data: {
                   currency: 'usd',
                   product_data: {
                       name: selected_item.sku.product.title,
                       images: [selected_item.sku.image_url]
                   },
                   unit_amount: selected_item.price.to_i * 100

               },
               quantity: selected_item.quantity,

            }

       end

       # create new stripe session
       session = Stripe::Checkout::Session.create({
       line_items: orderItems,
       payment_method_types: ['card'],
       shipping_address_collection: {
           allowed_countries: ['US', 'CA'],
           },
           shipping_options: [
           {
               shipping_rate_data: {
               type: 'fixed_amount',
               fixed_amount: {
                   amount: shipping_amount,
                   currency: 'usd',
               },
               display_name: 'Standard shipping',
               # Delivers between 5-7 business days
               delivery_estimate: {
                   minimum: {
                   unit: 'business_day',
                   value: 5,
                   },
                   maximum: {
                   unit: 'business_day',
                   value: 7,
                   },
               }
               }
           },
           {
               shipping_rate_data: {
               type: 'fixed_amount',
               fixed_amount: {
                   amount: 1500,
                   currency: 'usd',
               },
               display_name: 'Next day air',
               # Delivers in exactly 1 business day
               delivery_estimate: {
                   minimum: {
                   unit: 'business_day',
                   value: 1,
                   },
                   maximum: {
                   unit: 'business_day',
                   value: 1,
                   },
               }
               }
           },
           ],
       mode: 'payment',
       # append session id to success url so I can fetch the users order on frontend
       success_url:  ENV["WEBSITE_URL"] + 'order-confirmation?session_id={CHECKOUT_SESSION_ID}',
       cancel_url:    ENV["WEBSITE_URL"],
       })

       render json: {url: session.url}, status: :see_other
   end

   def order_success
       # see if order already exists
       order = Order.find_by(session_id: params[:session_id])
       if !order 
           create_order
           update_items   
       else
           @order = order
       end

       render json: @order, include: ['user', 'selected_items', 'selected_items.sku'], status: :accepted
   end


   private

   def set_stripe_key
       Stripe.api_key = ENV["STRIPE_API_KEY"]
   end

   def create_order
       # fetch order session and user from stripe
       session = Stripe::Checkout::Session.retrieve(params[:session_id])
       customer = Stripe::Customer.retrieve(session.customer)
       # add stripe id to user. create new order in database
       @current_user.update(stripe_id: customer.id)
       @order = @current_user.orders.create(
           session_id: params[:session_id],
           address: session.shipping.address,
           name: customer.name,
           email: customer.email,
           amount: session.amount_total / 100,
           status: 'Pending'
           )
       @order.invoice = "#{customer.invoice_prefix}-#{@order.id}"
       @order.save
   end

   def update_items
       # update sku quantity, remove cart association and add order association
       params[:items].each do |item|
           selected_item = SelectedItem.find_by(id: item)
           sku_qty = selected_item.sku.quantity - selected_item.quantity
           selected_item.sku.update(quantity: sku_qty)
           selected_item.update(order_id: @order.id, cart_id: nil)
       end
   end
end
Enter fullscreen mode Exit fullscreen mode

Frontend Implementation

Cart checkout button

I made it mandatory for a user to login in order to checkout. Once they were logged in, they were re-directed to the stripe checkout page.

const CartBtn = ({ loading }) => {
 let navigate = useNavigate()
 const cartItems = useRecoilValue(cartItemsAtom)
 const user = useRecoilValue(userAtom)
 const setCheckout = useSetRecoilState(checkoutAtom)
 const setToggleCart = useSetRecoilState(toggleCartOpenAtom)
 const startCheckout = useSetRecoilState(stripeCheckoutAtom)

 const handleCheckoutClick = () => {
   setCheckout(true)

   if (user) {
     startCheckout()
   } else {
     setToggleCart()
     navigate('/login')
   }
 }

 return (
   <Grid item>
     {cartItems?.length !== 0 ? (
       <LoadingButton
         onClick={handleCheckoutClick}
         loading={loading}
         variant='contained'
         className='btn btn-lg btn-100'
         color='info'>
         Continue To Checkout
       </LoadingButton>
     ) : (
       <Button
         variant='contained'
         className='btn btn-lg btn-100'
         color='info'
         disabled>
         Add Items To Cart
       </Button>
     )}
   </Grid>
 )
}

export default CartBtn
Enter fullscreen mode Exit fullscreen mode

StartCheckout Atom

I used Recoil to simplify my state management. So much easier and more intuitive, in my opinion, than using Redux with React.

export const stripeCheckoutAtom = selector({
 key: 'stripeCheckoutAtom',
 get: ({ get }) => get(cartOpenAtom),
 set: ({ get, set }) => {
   const cart = get(cartAtom)
   const items = get(cartItemsAtom)
   const cartItemsIds = items?.map((item) => item.id)
   const cartOpen = get(cartOpenAtom)
   fetch('/api/checkout', {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
     },
     body: JSON.stringify({
       items: cartItemsIds,
       shipping: cart.shipping,
     }),
   })
     .then((res) => res.json())
     .then((data) => {
       window.location = data.url
     })
     .catch((err) => console.log(err))
   set(cartOpen, false)
 },
})

Enter fullscreen mode Exit fullscreen mode

Order Confirmation Page

Once the user completes a successful Stripe checkout, they are redirected back to the order confirmation page. On this page, I retrieve the stripe session from the URL params. I struggled with this page and so I would like to spend a little more time streamlining this instead of having so many checks on load.

 const OrderConfirmation = () => {
 let navigate = useNavigate()
 const userOrders = useRecoilValue(userOrdersAtom)
 const cartItems = useRecoilValue(cartItemsAtom)
 const [user, setUser] = useRecoilState(userAtom)
 const [cart, setCart] = useRecoilState(cartAtom)

 const [loading, setLoading] = React.useState(true)
 const [error, setError] = React.useState(false)
 const [fetching, setFetching] = React.useState(false)
 const [order, setOrder] = React.useState(null)

 React.useEffect(() => {
   setLoading(true)
   setError(false)
   setFetching(false)
   //grab params from url
   const search = new URLSearchParams(window.location.search)

   if (search.has('session_id')) {
     //check if order already exists
     const session = search.get('session_id')
     const orderExists = userOrders.find(
       (order) => order.session_id === session
     )
     if (orderExists) { //display if it does
       setOrder(orderExists)
       setLoading(false)
       setFetching(false)
     } else {
       if (cartItems && cartItems?.length !== 0) {
         handleFetchStripeOrder(session) //if it doesn't or there are no orders fetch stripe order
         setFetching(true)
       } else {
         setLoading(false)
         setFetching(true)
       }
     }
   } else {
     setLoading(false)
     setError(true)
   }
 }, [cartItems, order, user])

 const handleFetchStripeOrder = (session_id) => {
   const items = handleCheckoutItems()
   fetch('/api/order-success', {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
     },
     body: JSON.stringify({
       session_id,
       items,
     }),
   })
     .then((res) => res.json())
     .then((data) => {
       setOrder(data)
       const newUser = { ...user, orders: [...userOrders, data] }
       setUser(newUser)
       const newCart = {
         ...cart,
         total: '0',
         shipping: '0',
         item_count: 0,
         selected_items: [],
       }
       setCart(newCart)
       setLoading(false)
       setError(false)
       setFetching(false)
     })
     .catch((err) => {
       console.error(err)
       setLoading(false)
       setError(true)
       setFetching(false)
     })
 }

 const handleCheckoutItems = () => {
   return cartItems?.map((item) => {
     return item.id
   })
 }

 return (
     //jsx here

 )
}
export default OrderConfirmation


Enter fullscreen mode Exit fullscreen mode

AWS Image Uploads

I found this to be the most frustrating part of my project. I cannot explain the highs and lows I felt throughout this portion. I first implemented image upload with ActiveStorage and AWS. Once I had it working in development, I felt great! Then, I pushed it live to Heroku and it stopped working.

I was sending the image to my backend to handle the AWS upload, and Heroku does not let you send more than 4MB to the backend. Once I researched this more, I realized it’s more efficient to upload directly to AWS. It is more efficient and saves on server CPU usage.

I am planning on writing another blog post solely dedicated to AWS and how to direct upload with ActiveStorage and React for anyone else struggling!

sku image upload

Final Thoughts

Our capstone project is supposed to push us farther than our other projects and I believe this project did that for me. Honestly, I am SO PROUD of this project. I incorporated everything Flatiron has taught me plus learned new skills for this project on my own (using Recoil, stripe, and AWS). I also loved this project so much because I got to incorporate my current working knowledge of eCommerce digital marketing into this online store.

Comparing this to my first project with Flatiron, it feels so rewarding to see my growth. Even though Flatiron is wrapping up, my goal in development is to constantly learn and grow my skill set. I am an innate learner and it’s one of the reasons I am so drawn to web development – there is always something new to learn and room for improvement.

If you would like to see my project in action you can view it here ❤️

💖 💪 🙅 🚩
christinecontreras
Christine Contreras

Posted on February 6, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related