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!