Building a Stripe Powered Shopping Cart

by Nick DeJesus

Twitter: @dayhaysoos

Architecture

Shopping Cart 🛒 

IRL Shopping Cart Logic

  • Put items in
  • Take items out

Internet Shopping Cart Logic

  • State management
  • Calculate Total Prices
  • Calculate Total items
  • Data modeling
  • Backend integration
  • Edge Cases 😱 

Mental Model

Shopping Carts are mini CRUD Apps

  • Create - Add Items to Cart
  • Read - Display data from Cart
  • Update - Increase/Decrease amount of Cart Items
  • Delete - Remove Items from Cart

Extra Stuff

  • Keeping track of last clicked Item
  • Compatibility with Payment Processor APIs like Stripe
  • Specific things for your business

use-shopping-cart

  • React Hooks
  • Stripe Powered
  • Jamstack friendly
  • Works Offline
  • Fundamental shopping cart needs
  • Client Side and Serverless implementations
  • Pretty cool

Why Choose Stripe

  • Easy to use API
  • Very thorough documentation
  • Tons of content around it
  • Stripe Checkout (hella convenient)

Stripe.redirectToCheckout()

stripe
  .redirectToCheckout({
    lineItems: [
      // Replace with the ID of your price
      {price: 'price_123', quantity: 1},
    ],
    mode: 'payment',
    successUrl: 'https://your-website.com/success',
    cancelUrl: 'https://your-website.com/canceled',
  })
  .then(function(result) {
    console.log(result)
  });

Important stuff

Stripe.redirectToCheckout()

  lineItems: [
      {price: 'price_123', quantity: 1},
    ],

lineItems essentially is the shopping cart just before the user checks out. 

Problems

But it doesn't have half the information that we need for basic e-commerce experience.

What is a basic e-commerce experience?

  • Display product and info
  • Add/Remove items from cart
  • Interactive shopping cart icon
  • Display what's in cart
  • Update quantity of items in cart
  • Page per product

Cart Details

{
  "sku_GBJ2Ep8246qeeT": {
    "name": "Bananas",
    "sku": "sku_GBJ2Ep8246qeeT",
    "price": 400,
    "image": "image-url here",
    "currency": "USD",
    "quantity": 1,
    "value": 400,
    "formattedValue": "$4.00"
  }
}

The Object below is an ideal structure for your shopping cart

Entry class

function Entry(productData, quantity, currency, language) {
  return {
    ...productData,
    quantity,
    get value() {
      return this.price * this.quantity
    },
    get formattedValue() {
      return formatCurrencyString({
        value: this.value,
        currency,
        language
      })
    }
  }
}

This class is the focal point of handling the cart values

createEntry

 function createEntry(product, count) {
    const entry = Entry(product, count, action.currency, action.language)

    return {
      cartDetails: {
        ...state.cartDetails,
        [product.sku]: entry
      },
      totalPrice: state.totalPrice + product.price * count,
      cartCount: state.cartCount + count
    }
  }

createEntry() is a pure function that returns the desired structure of an item that's been added to the cart

updateEntry()

  function updateEntry(sku, count) {
    const cartDetails = { ...state.cartDetails }
    const entry = cartDetails[sku]
    if (entry.quantity + count <= 0) return removeEntry(sku)

    cartDetails[sku] = Entry(
      entry,
      entry.quantity + count,
      action.currency,
      action.language
    )

    return {
      cartDetails,
      totalPrice: state.totalPrice + entry.price * count,
      cartCount: state.cartCount + count
    }
  }

updateEntry() is a pure function that returns an updated version of the current cart state

removeEntry()

function removeEntry(sku) {
    const cartDetails = { ...state.cartDetails }
    const totalPrice = state.totalPrice - cartDetails[sku].value
    const cartCount = state.cartCount - cartDetails[sku].quantity
    delete cartDetails[sku]

    return { cartDetails, totalPrice, cartCount }
  }

removeEntry() is a function that removes an item from the current cart state

updateQuantity()

  function updateQuantity(sku, quantity) {
    const entry = state.cartDetails[sku]
    return updateEntry(sku, quantity - entry.quantity)
  }

updateQuantity is a pure function that returns cartDetails after updating the quantity of an item

All Entry functions are wrapped in a reducer

function cartValuesReducer(state, action) {
  function createEntry() { ... }
  function updateEntry() { ... }
  function removeEntry() { ... }
  function updateQuantity() { ... }
  
  // switch cases below!

}

Entry functions get called by switch cases

switch (action.type) {
    case 'add-item-to-cart':
      if (action.count <= 0) break
      if (action.product.sku in state.cartDetails)
        return updateEntry(action.product.sku, action.count)
      return createEntry(action.product, action.count)
}

The remaining cases

    case 'increment-item':
      if (action.count <= 0) break
      if (action.sku in state.cartDetails)
        return updateEntry(action.sku, action.count)
      break

    case 'decrement-item':
      if (action.count <= 0) break
      if (action.sku in state.cartDetails)
        return updateEntry(action.sku, -action.count)
      break

    case 'set-item-quantity':
      if (action.count < 0) break
      if (action.sku in state.cartDetails)
        return updateQuantity(action.sku, action.quantity)
      break

    case 'remove-item-from-cart':
      if (action.sku in state.cartDetails) return removeEntry(action.sku)
      break

    case 'clear-cart':
      return cartValuesInitialState

You still need more than cart values!

Remember, you're creating an experience. Having data on just the values of the cart isn't enough. You need values that describe the state of the cart

Basic Cart States

  • Cart Hover
  • Cart Click
  • Last Clicked Product
  • Should cart be showing

Preparing for Checkout

const lineItems = []
    for (const sku in cart.cartDetails)
      lineItems.push({ price: sku, quantity: cart.cartDetails[sku].quantity })

Client Only vs Serverless

Everything we've gone through so far was about using Stripe's Checkout on the Client side, but what about Serverless implementations?

stripe.redirectoToCheckout()

again

Instead of the lineItems array, redirectToCheckout also accepts a sessionId

stripe.redirectToCheckout({ sessionId: session.id });

Security Concerns

 

Without a server, it's possible that a malicious user can update the fields on the front end before hitting checkout

Source of Truth

 

Use this to refer to the real values of your product

[
  {
    "name": "Bananas",
    "sku": "sku_GBJ2Ep8246qeeT",
    "price": 400,
    "image": "banana-url",
    "currency": "USD"
  },
  {
    "name": "Tangerines",
    "sku": "sku_GBJ2WWfMaGNC2Z",
    "price": 100,
    "image": "tangerine-url",
    "currency": "USD"
  }
]

Validate the cart items

validateCartItems() returns the real product values and formats them to be used as lineItems for Stripe's Checkout

const validateCartItems = (inventorySrc, cartDetails) => {
  const validatedItems = []
  for (const sku in cartDetails) {
    const product = cartDetails[sku]
    const inventoryItem = inventorySrc.find(
      (currentProduct) => currentProduct.sku === sku
    )
    if (!inventoryItem) throw new Error(`Product ${sku} not found!`)
    const item = {
      name: inventoryItem.name,
      amount: inventoryItem.price,
      currency: inventoryItem.currency,
      quantity: product.quantity
    }
    if (inventoryItem.description) item.description = inventoryItem.description
    if (inventoryItem.image) item.images = [inventoryItem.image]
    validatedItems.push(item)
  }

  return validatedItems
}

Netlify Function to Grab Stripe's SessionId

const inventory = require('./data/products.json')

exports.handler = async (event) => {
  try {
    const productJSON = JSON.parse(event.body)

    const line_items = validateCartItems(inventory, productJSON)

    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      billing_address_collection: 'auto',
      shipping_address_collection: {
        allowed_countries: ['US', 'CA']
      },
      success_url: `${process.env.URL}/success.html`,
      cancel_url: process.env.URL,
      line_items
    })

    return {
      statusCode: 200,
      body: JSON.stringify({ sessionId: session.id })
    }
  } catch (error) {
    console.error(error)
  }
}

line_items is the result of validated cart items

What about user data?

You may want to add some user-centric values to your shopping cart state to better reflect an authenticated experience

You can use event trigger functions for every cart interaction so that you can update your database to reflect these changes. This would be an agnostic approach to supporting backends.

5 Utility Functions you wish you had with your shopping cart

 

  • isClient: Checks if use-shopping-cart is being used client-side or server-side
  • formatCurrencyString: Makes sure your product prices are formatted properly based on the country
  • useLocalStorageReducer: Helper to make sure your localStorage properly reflects cart state
  • checkoutHandler: validates if you're using a client-side or sessionId checkout
  • getCheckoutData: Only gets called for client-side only, formats lineItems for proper checkout

Possibilities are endless

Every business is unique. You might need to do something different from everyone else. Having a solid foundation for your shopping cart gives you the flexibility to be creative with your checkout experiences.

Checkout use-shopping-cart!

use-shopping-cart handles mostly everything in these slides

Thank you!

nick@echobind.com

deck

By Nick De Jesus

deck

  • 376