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
PLP (Category Page)
PDP (Product Page)
Cart
Admin Dashboard
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
- 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
- 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
- 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
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
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
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
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
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)
},
})
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
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!
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 ❤️