9 November 2022
Adding a Stripe cart to a static Eleventy website with LiveState: Part 1
Adding a Stripe cart to a static Eleventy website with LiveState: Part 1
In my previous article, I introduced LiveState, a library to facilitate building embedded applications, or apps that add functionality to a larger apps. For this series, I decided to tackle a classic example: the humble shopping cart. I was interested in the question of how you could add a shopping cart to a statically generated site. After googling a bit to see who else was working on this problem, I landed on an excellent series of videos and posts by Sia Karamelegos. It walks through how to integrate an Eleventy generated blog with Stripe. In the example, Sia uses the Stripe API from 11ty to generate a static html page listing all of her products with a Buy Now button for each. The Buy Now button then redirects to stripe to complete the checkout process. Here’s what the existing site looks like:
This is a great start, but there are some limitations: the main one being you can only buy one item a time. It would be even better if the buy button allowed you to add items to a shopping cart so that you can purchase multiple items before you are taken to the Stripe checkout. And we’d like to be able to do this while still keeping all the benefits that a statically generated site gives us.
Here’s what we’ll want our final result to look like:
In this new version, there are two custom HTML elements we add to the page. A <stripe-cart-additem>
element wraps the existing Buy Button and causes it to adds items to the cart. A <stripe-cart>
element renders the basket icon with the number of items, as well as an expandable modal that displays the full item info and gives the user a way to complete the checkout.
Let’s talk about how we can build these elements and how they will interact with our back end. To keep things simple, we want to follow a well established pattern for building “dumb” components. We want our custom elements to ideally do only three things:
- Render state
- Dispatch events
- Respond to events
That sounds great, but what handles these dispatched events, and how does the state get updated as a result? This is where LiveState comes in. It includes a back-end library to let us write our event handler functions to recompute state, and a front end library to send events and receive state updates in our custom elements. Importantly, LiveState does not require our back end to be deployed to the same server that serves up our html. This means we can keep on keeping on with our statically rendered site, and drop in LiveState to to connect to the back end that does our needed work. In our example, here’s a diagram showing what the event flow looks like:
sequenceDiagram participant AIB as Add Item Buttom participant Cart participant LiveState participant Stripe LiveState ->> Stripe: fetch product info AIB ->> LiveState: add_item event LiveState ->> Cart: cart state update Cart ->> LiveState: checkout event LiveState ->> Stripe: create checkout session LiveState ->> Cart: checkout_redirect
Next let’s start looking at our custom element code.
<stripe-cart-additem>
This element is designed to drop into the site we already have and wrap the buy button. It has no state to render: it’s job is just to handle the buy button click events and turn them into custom events that add items to our cart.
Here’s what it looks like:
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { liveState } from 'phx-live-state';
@customElement('stripe-cart-additem')
@liveState({
events: {
send: ['add_cart_item']
},
context: 'cartState'
})
export class StripeCartAddItemElement extends LitElement {
@property({attribute: 'price-id'})
priceId = '';
constructor() {
super();
this.addEventListener('click', (event) => {
console.log(event);
this.dispatchEvent(new CustomEvent('add_cart_item', {detail: {stripe_price: this.priceId}}))
});
}
render() {
return html`<slot></slot>`;
}
}
Up at the very top, we see our liveState
decorator. We declare that we want to send an add_cart_item
event. Since we don’t need render anything from the state, we don’t have any properties declared. Finally, you’ll see a brand spanking new feature of LiveState: the context
. This element is designed to share a live state context with the cart element we will get to shortly. Because it is a consumer, not a provider, it can just declare that it needs a context and LiveState will work things out.
We take single attribute price-id
that is the id of the Stripe price for the item we want to add. Why a price id and not a product id? The short answer is: it’s just easier. It wouldn’t be too hard to support a product id or a price id, but we’ll leave that for later.
Next we can see that we are listening for a click event on ourselves, and when we get one dispatching an add_cart_item
event with the Stripe price id of the item.
Now let’s look at our template: hey wait a second, there’s nothing there that dispatches a click event! What’s up with that? The dealio is that this element is designed to wrap a button that’s already on the page. Here’s how it is designed to be used:
<stripe-cart-additem price-id="price_1LoAhVKFGxMzGbgkUgrHPg0z">
<button>Add a thing to the cart</button>
</stripe-cart-additem>
We have a button as the inner content of our element. The button rendered into our component’s shadow DOM using the slot
element. Because click events naturally bubble, we can listen to this event in our <stripe-cart-additem>
element.
<stripe-cart>
Now let’s look at the cart itself. Its’ job is to display a small “mini” cart with an icon and number of items, an expandable modal of detailed cart info, and a button to intitiate the checkout process. Here’s the code:
import { html, LitElement } from 'lit'
import { customElement, query, state } from 'lit/decorators.js'
import { liveState } from 'phx-live-state';
type CartItem = {
product: Product;
quantity: number;
}
type Product = {
id: string;
amount: number;
product: StripeProduct
}
type StripeProduct = {
description: string;
id: string;
images: string[];
}
type Cart = {
items: Array<CartItem>;
total: number;
}
@customElement('stripe-cart')
@liveState({
channel Name: 'stripe_cart:new',
url: 'ws://localhost:4000/socket',
properties: ['cart'],
provide: {
scope: window,
name: 'cartState'
},
events: {
send: ['checkout'],
receive: ['checkout_redirect']
}
})
export class StripeCartElement extends LitElement {
@state()
cart: Cart | undefined;
@query('sl-dialog')
dialog: HTMLElement | undefined;
constructor() {
super();
this.addEventListener('checkout_redirect', (e: CustomEvent<{ checkout_url: string }>) => {
window.location.href = e.detail.checkout_url;
});
}
itemCount() {
return this.cart && this.cart.items && this.cart.items.length > 0 ? html`
<sl-badge pill>${this.cart.items.length}</sl-badge>
` : ``;
}
expandCart() {
this.dialog && (this.dialog as any).show();
}
checkout(_e: Event) {
this.dispatchEvent(new CustomEvent('checkout', { detail: { return_url: window.location.href } }))
}
render() {
return html`
<sl-dialog>
<table>
<thead>
<tr>
<th>Item</th>
<th>Quantity</th>
<th>Price</th>
</tr>
</thead>
<tbody>
${this.cart?.items.map(item => html`
<tr>
<td>${item.product.product.description}</td>
<td>${item.quantity}</td>
<td>${item.product.amount}</td>
</tr>
`)}
</tbody>
</table>
<button @click=${this.checkout}>Check out</button>
</sl-dialog>
<sl-button @click=${this.expandCart}>
<sl-icon name="cart" style="font-size: 2em;"></sl-icon>
${this.itemCount()}
</sl-button>
`;
}
}
declare global {
interface HTMLElementEventMap {
'checkout_redirect': CustomEvent<{ checkout_url: string }>;
}
}
There’s a lot more code in the cart component but I’ll try to explain the most important bits. I’ve decided to use typescript for my components, and so at the top I have some type definitions for the cart and related bits. This structure is a bit more complicated than it might otherwise be due to our cart items being related to stripe data objects.
Next, we see the liveState decorator. We have this component providing the LiveState instance to other components, so it has a provide attribute with a scope and name. This will result in this LiveState instance being in the cartState
property of window
, and will allow the <stripe-cart-additem>
element to find it. It’s worth mentioning that this will work correctly regardless of which element is declared first. We also see that LiveState will manage the cart
property, and will send a checkout
event and receive a checkout_redirect
event.
In the class body, we have a couple of properties, the cart itself and a reference to the dialog element. We’ll be leveraging a nifty custom element library called Shoelace to provide the dialog and the minicart icon.
In the constructor we see a listener for the checkout_redirect
event which expects a url to redirect to. This is how we take the user to Stripe to complete the checkout once we’ve created the checkout session with all of the cart information. We’ll get to these details in the next installment.
Finally, we’ve got a few helper functions and then the template itself. The template renders the minicart, and a dialog with the cart details. The dialog is expanded by clicking the minicart icon.
Well, this seems like a decent stopping point. In the next installment, as promised, I’ll get into the details of what happens on the backend. We’ll see where all those front end events are received and how the interaction with Stripe happens. See you soon!