Adding a Stripe cart to a static Eleventy website with LiveState: Part 2
17 January 2023

Adding a Stripe cart to a static Eleventy website with LiveState: Part 2

Adding a Stripe cart to a static Eleventy website with LiveState: Part 2

Welcome back! In the previous installment we looked at the custom elements we need to add shopping cart functionality to our statically generated website. We were able to keep the code pretty simple: just dispatching events and rendering state. Now it’s time to look at where those events are handled and how new state is computed.

The action happens in StripeCartWeb.StripeCartChannel:

defmodule StripeCartWeb.StripeCartChannel do
  use LiveState.Channel, web_module: StripeCartWeb

  alias StripeCart.Cart
  alias LiveState.Event

  def init(_channel, _payload, _socket) do
    {:ok, %{}}
  end

  def handle_event("add_cart_item", %{"stripe_price" => stripe_price}, %{cart: cart} = state) do
    case Cart.add_item(cart, stripe_price) do
      {:ok, cart} -> {:noreply, Map.put(state, :cart, cart)}
    end
  end

  def handle_event("add_cart_item", %{"stripe_price" => stripe_price}, state) do
    case Cart.add_item(stripe_price) do
      {:ok, cart} -> {:noreply, Map.put(state, :cart, cart)}
    end
  end

  def handle_event("checkout", %{"return_url" => return_url}, %{cart: cart} = state) do
    case Cart.checkout(return_url, cart) do
      {:ok, %Stripe.Session{url: checkout_url}} ->
        {:reply, %Event{name: "checkout_redirect", detail: %{checkout_url: checkout_url}}, Map.put(state, :cart, nil)}
    end
  end
end

A LiveState channel is simply a Phoenix Channel that uses the LiveState.Channel behaviour and implements the required callbacks. In our case, we have a very simple init that returns an empty state map. This is because our Cart is created lazily the first time a user adds an item. It also means we get a new cart every time the channel is joined. We’ll want to add cart persistence in a later iteration, but we’ll keep it simple to start.

Next, we see two implementations of the event handler for the add_cart_item event dispatched by the client. The first version handles the case where we already have a cart, the second version handles the case where are add the first item to a brand new cart. Both versions use a :noreply tuple to return a new state, and delegate to our Cart context to do the work of finding the item with a given stripe_price and creating or modifying the Cart struct.

Now let’s look at the handle_event function for checkout. This hands off to the Cart module which converts our cart items into the correct format to call the Stripe Checkout API. Stripe Checkout returns a url as part of the Stripe.Session. Our event handler returns a :reply 3-tuple which contains an Event struct and a new state. On the client, this is converted to a Custom Event and dispatched. As we saw in the previous installment, our stripe-cart element is listening for the checkout_redirect custom event and will redirect the window to checkout_url. We then end up on the Stripe checkout page with all our items and can’t finish the process. Finally, we’ll get redirected back to our page from Stripe when the checkout is complete.

This is really the end of the LiveState specific stuff. For completeness, we can look at the Cart module as well. In later versions of this code, it gets more complex as I added things like cart persistence. For this article I’ve deliberately kept things as simple as I could:

defmodule StripeCart.Cart do
  alias StripeCart.{Cart, CartItem}

  @derive Jason.Encoder

  @create_checkout_session Application.get_env(
                             :stripe_cart,
                             :create_checkout_session,
                             &Stripe.Session.create/1
                           )

  defstruct items: [], id: nil

  def add_item(price_id) do
    case Cachex.get(:stripe_products, price_id) do
      {:ok, product} -> {:ok, %Cart{items: [%CartItem{quantity: 1, product: product}]}}
      _ -> {:error, "Product not found"}
    end
  end

  def add_item(cart, price_id) do
    case Cachex.get(:stripe_products, price_id) do
      {:ok, product} -> {:ok, add_product(cart, product)}
      _ -> {:error, "Product not found"}
    end
  end

  def add_product(%Cart{items: items}, product) do
    case Enum.find_index(items, fn %CartItem{product: cart_product} ->
           cart_product.id == product.id
         end) do
      nil ->
        %Cart{items: items ++ [%CartItem{quantity: 1, product: product}]}

      index ->
        %CartItem{quantity: quantity} = Enum.at(items, index)

        %Cart{
          items:
            List.replace_at(items, index, %CartItem{product: product, quantity: quantity + 1})
        }
    end
  end

  def checkout(return_url, %Cart{items: items}) do
    @create_checkout_session.(%{
      mode: "payment",
      success_url: return_url,
      cancel_url: return_url,
      line_items: Enum.map(items, &build_line_item/1)
    })
  end

  defp build_line_item(%CartItem{
         quantity: quantity,
         product: %{id: price}
       }),
       do: %{quantity: quantity, price: price}
end

This code is a struct module which also contains functions for working with Cart structs. We see two different function heads for add_item, one of which creates a new cart, and one which adds an item to an existing cart. We are using Cachex to avoid hitting the Stripe API every time we add a product to a cart. We have a cache warmer which fetches all our Stripe products on application startup and puts them in a :stripe_products cache, indexed by the price_id.

The add_product function looks a little more complicated, but it is really just adding a new line item, or incrementing the quantity for a product that is already in the cart.

Finally we get to our checkout function. This builds up the data to send to the Stripe create_checkout_session api. We have a bit of indirection setup via a module attribute so that we can use a mock during our tests.

And this is pretty much all there is to building a library of Custom Elements we can use to add a shopping cart to a plain old HTML website. Hopefully you were able to see how LiveState made this considerably easier to build than a full request/response API would be.

The full code for this application is available here:

Thanks for reading!

Related Posts

Want to learn more about the work we do?

Explore our work

Ready to start your software journey with us?

Contact Us