Seamless Shopping Made Easy: Building an Intuitive ‘Add to Cart’ Feature with Phoenix LiveView

Michael Munavu
13 min readJun 12, 2023

--

In the world of e-commerce, a smooth and intuitive shopping experience is essential for attracting and retaining customers. One crucial aspect of this experience is the “Add to Cart” functionality, which allows users to easily select and store items they wish to purchase. In this article, we will explore how to implement this vital feature in a Phoenix LiveView e-commerce project. By leveraging the power of Phoenix LiveView, we can create a real-time and dynamic shopping cart that enhances the user experience and simplifies the buying process. So, let’s dive in and discover how to build an efficient and user-friendly “Add to Cart” functionality!

We shall create a project first , call it liveview_ecommerce

mix phx.new liveview_ecommerce

Let us configure our database in the config/dev.exs by changing the user name and password , then running

mix ecto.create

We will start with creating users , then products , then later we will move on to creating carts .

Let us create users with phx.gen.auth which will handle authentication for us .

mix phx.gen.auth Users User users

Please re-fetch your dependencies with the following command:

mix deps.get

Remember to update your repository by running migrations:

mix ecto.migrate

Let us now add our products , they will have a name , price and images .

We will first add the name and price then move onto the images .

We will be generating live views for this using the command .

mix phx.gen.live Products Product products name:string price:integer

Before we migrate this to the database , let us add the images , we would want to be able to add multiple images for every one of our products , we first have to add this column to our migration file and schema definitions , we will have images as either an array or string

This is the syntax for that column in postgres .

add(:column, {:array, :string}, default: [])

So in our case , we first jump inside the priv/repo/migrations/create_products and modify it to

defmodule LiveviewEcommerce.Repo.Migrations.CreateProducts do
use Ecto.Migration

def change do
create table(:products) do
add(:name, :string)
add(:price, :integer)
add(:images, {:array, :string}, default: [])

timestamps()
end
end
end

Let us now move to our schema definitions and add the same

inside lib/liveview_ecommerce/products/product.ex .

Modify it to

defmodule LiveviewEcommerce.Products.Product do
use Ecto.Schema
import Ecto.Changeset

schema "products" do
field :name, :string
field :price, :integer
field :images, {:array, :string}, default: []

timestamps()
end

@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:name, :price, :images])
|> validate_required([:name, :price, :images])
end
end

Now we have a product with a name , price and images .

Let us migrate this to our database now with

mix ecto.migrate

Add the live routes to your browser scope in lib/liveview_ecommerce_web/router.ex:

live "/", ProductLive.Index, :index
live "/products/new", ProductLive.Index, :new
live "/products/:id/edit", ProductLive.Index, :edit
live "/products/:id", ProductLive.Show, :show
live "/products/:id/show/edit", ProductLive.Show, :edit

Also remove this line

  get "/", PageController, :index

So that in the index page , we will see our products .

We would want to see our products after signing up , for this , we will use the require_authenticated_user function , edit your browser scope to


scope "/", LiveviewEcommerceWeb do
pipe_through [:browser, :require_authenticated_user]

live "/", ProductLive.Index, :index
live "/products/new", ProductLive.Index, :new
live "/products/:id/edit", ProductLive.Index, :edit

live "/products/:id", ProductLive.Show, :show
live "/products/:id/show/edit", ProductLive.Show, :edit
end

Now , let us fire up our application and see what we have so far ,

mix phx.server

We are first taken to our login and signup screens before we view our products , just as we wanted .

After logging in or signing up , we see our products .

Before we proceed onto adding new products , we need to handle the uploads , luckily live view has helpers for this , I recently wrote an article on uploading images in phoenix live view , but we will be going through the gist of it here ,

How to Allow Live Uploads

You enable an upload via allow_upload/3

We will also assign an empty variable array where we will store our uploaded images.

Let us go into

lib/liveview_ecommerce_web/live/poduct_live/form_component.ex

In the update function, we will add these two lines

|> assign(:uploaded_files, [])
|> allow_upload(:product_images, accept: ~w(.jpg .png .jpeg), max_entries: 3)}

So now the update function in full is as follows

def update(%{product: product} = assigns, socket) do
changeset = Products.change_product(product)

{:ok,
socket
|> assign(assigns)
|> assign(:changeset, changeset)
|> assign(:uploaded_files, [])
|> allow_upload(:product_images, accept: ~w(.jpg .png .jpeg), max_entries: 1)}
end
here, we have 2 variables, uploaded_files which will store our files and product_images.

We shall be using the product_images variable a lot down below.

Render the File Upload Field

You’ll use the Phoenix.LiveView.Helpers.live_file_input/2 function to generate the HTML for a file upload form field. Here's a look at our form component template:

In our lib/liveview_ecommerce_web/live/poduct_live/form_component.html.heex

We add

   <div >
<p >
Product Images
</p>
<%= live_file_input(@uploads.product_images ) %>
<span >
Add up to 3 images
</span>
</div>

Now let us go view our form and see the changes .

When we add our images , live view allows us to preview these images and cancel the ones we do not want , amazing feature , right?

To do this , just below our live file input , we add

 <%= for entry <- @uploads.product_images.entries do %>
<div>
<%= live_img_preview(entry, style: "width: 100px; height: 100px;") %>
<button
type="button"
phx-click="cancel-upload"
phx-value-ref={entry.ref}
phx-target={@myself}
>
Cancel
</button>
</div>
<% end %>

Now we will be able to preview our image and see a cancel button

Let us add a function in our form_component.ex to handle our cancel-upload event .

def handle_event("cancel-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :product_images, ref)}
end

Now we can upload images , preview them and cancel the ones we do not need .

We finally need to consume these images , we will edit the handle_event(“save”) in our form_component.ex from

def handle_event("save", %{"product" => product_params}, socket) do
save_product(socket, socket.assigns.action, product_params)
end

to

def handle_event("save", %{"product" => product_params}, socket) do
uploaded_files =
consume_uploaded_entries(socket, :product_images, fn %{path: path}, _entry ->
dest =
Path.join([:code.priv_dir(:liveview_ecommerce), "static", "uploads", Path.basename(path)])

# The `static/uploads` directory must exist for `File.cp!/2`
# and MyAppWeb.static_paths/0 should contain uploads to work,.
File.cp!(path, dest)
{:ok, "/uploads/" <> Path.basename(dest)}
end)

{:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))}

new_product_params = Map.put(product_params, "images", uploaded_files)

save_product(socket, socket.assigns.action, new_product_params)
end

This function consumes the images that are stored in the uploads folder inside priv/static , create a folder and call it uploads inside static .

This is where your images will be stored , it will be an array , so when accessing the product_images , we will be accessing them as an array .

new_product_params = Map.put(product_params, "product_images", uploaded_files)

This line of code maps through our params and matches the images column in our database to the uploaded files , let us now add a way to view the uploaded images .

Inside this line , make sure to change , liveview_ecommerce to the name of your app .

Path.join([:code.priv_dir(:liveview_ecommerce), "static", "uploads", Path.basename(path)])

In lib/liveview_ecommerce_web/live/product_live/index.html.heex .

Let us create a new Column where we are looping through our products for these images

 <tr>
<th>Images</th>
<th>Name</th>
<th>Price</th>

<th>
Actions
</th>
</tr>

And the corresponding

 <tr id={"product-#{product.id}"}>
<td>
<%= for image <- product.images do %>
<img src={image} />
<% end %>
</td>
<td><%= product.name %></td>
<td><%= product.price %></td>

<td>
<span>
<%= live_redirect("Show", to: Routes.product_show_path(@socket, :show, product)) %>
</span>
<span>
<%= live_patch("Edit", to: Routes.product_index_path(@socket, :edit, product)) %>
</span>
<span>
<%= link("Delete",
to: "#",
phx_click: "delete",
phx_value_id: product.id,
data: [confirm: "Are you sure?"]
) %>
</span>
</td>

We are looping through the images and displaying them all in the products table in our root page .

Congratulations, we have added and consumed images in phoenix live view.

To be able to access the images in our uploads folder, we have to add them in our lib/liveview_ecommerce_web/endpoint.ex

Inside this plug

plug Plug.Static,
at: "/",
from: :liveview_ecommerce,
gzip: false,
only: ~w(assets fonts images favicon.ico robots.txt)

let us add uploads so we now have

plug Plug.Static,
at: "/",
from: :liveview_ecommerce,
gzip: false,
only: ~w(assets fonts images favicon.ico robots.txt uploads)

Now we can fire up our project and everything should work .

We can now add up to 3 images , a name and a price for our products .

Now let us move on to adding the cart functionality .

When we think about it , a cart has a quantity which is defaulted to 1 , a product and a user .

So that means that each product will have an add to cart button , when this button is clicked , it should take a the product clicked and the user logged in as params and create a cart with these parameters .

We will borrow this phenomenon from the deletes action , which when clicked , it takes the id of the product and deletes the record clicked .

Let us create our live views for our cart .

mix phx.gen.live Carts Cart carts quantity:integer user_id:references:users product_id:references:products

This generates this migration file ,

defmodule LiveviewEcommerce.Repo.Migrations.CreateCarts do
use Ecto.Migration

def change do
create table(:carts) do
add :quantity, :integer
add :user_id, references(:users, on_delete: :nothing)
add :product_id, references(:products, on_delete: :nothing)

timestamps()
end

create index(:carts, [:user_id])
create index(:carts, [:product_id])
end
end

We need to add a default value to our quantity to 1 ,let us modify

add :quantity, :integer

to

add :quantity, :integer , default: 1

And in our schema file in

lib/liveview_ecommerce/cart.ex modify it to

defmodule LiveviewEcommerce.Carts.Cart do
use Ecto.Schema
import Ecto.Changeset

schema "carts" do
field(:quantity, :integer, default: 1)
belongs_to(:user, LiveviewEcommerce.Users.User)
belongs_to(:product, LiveviewEcommerce.Products.Product)

timestamps()
end

@doc false
def changeset(cart, attrs) do
cart
|> cast(attrs, [:quantity, :user_id, :product_id])
|> validate_required([:quantity, :user_id, :product_id])
end
end

We will change the delete handle event for our product such that it first removes all carts that have the product clicked then delete the product .

In lib/liveview_ecommerce_web/live/product_live/index.ex change that handle_event delete function to

def handle_event("delete", %{"id" => id}, socket) do
product = Products.get_product!(id)

Carts.delete_cart_by_product(product)

{:ok, _} = Products.delete_product(product)

{:noreply, assign(socket, :products, list_products())}
end

In the carts module we need to add the delete_cart_by_product function .

This will be inside our lib/liveview_ecommerce/carts.ex

 def delete_cart_by_product(product) do
Repo.delete_all(from(p in Cart, where: p.product_id == ^product.id))
end

This ensures that we can delete a product even if it is in the cart.

We have added a default to our quantity field and casted and validated our user and product id to make sure they are there .

We have also defined our relationships to the user and product .

Add the live routes to your browser scope in lib/liveview_ecommerce_web/router.ex:

live "/carts", CartLive.Index, :index

Remember to update your repository by running migrations:

mix ecto.migrate

Let us to to our carts route and see what we have,

This is because there is a new cart button defined to a route that we have not added ,

Go inside lib/liveview_ecommerce_web/live/cart_live/index.html.heex and remove this line

<span><%= live_patch "New Cart", to: Routes.cart_index_path(@socket, :new) %></span>

And these two , in the actions td

   <span><%= live_redirect "Show", to: Routes.cart_show_path(@socket, :show, cart) %></span>
<span><%= live_patch "Edit", to: Routes.cart_index_path(@socket, :edit, cart) %></span>

Now in our carts route , we see this

Now let us configure our add to cart functionality , for this we will first create a button that allows us to add to cart.

Let us to to our inde.html.heex for our product_live

Add a new row with

<th>
Cart
</th>

And where we loop through our products , add a new td

<td>
<button>
<%= link("Add to cart",
to: "#",
phx_click: "add_to_cart",
phx_value_id: product.id,
style: "color: white;"
) %>
</button>
</td>

We have added a click event called add_to_cart , we need to define a function to handle this event in our product_live/index.ex , this function will be taking the logged in user and the product clicked and pushing it to our carts table .

First we need to get our logged in user , we will use the

get_user_by_session_token

function that gives us access to the logged in user when the session is passed in ,

Inside our index.ex , we need to alias our Users module to access this method .

So at the top add

  alias LiveviewEcommerce.Users

In our mount function which is called when the products mount , we have this ,

  def mount(_params, _session, socket) do

{:ok, assign(socket, :products, list_products())}
end

This function assigns the socket products , we need to call

Users.get_user_by_session_token

which takes in the session as a parameter , to get access to the session , remove the underscore in the parameters for mount , so now we have

_params, session, socket

as parameters .

We now modify the mount function to

def mount(_params, session, socket) do
user = Users.get_user_by_session_token(session["user_token"])

{:ok,
socket
|> assign(:products, list_products())
|> assign(:user, user)}
end

This assigns a user to our socket which we can now use throughout our product_live with

socket.assigns.user

Now , let us create a function to handle our “add_to_cart” event .

We will be creating a cart , so we need to use the

create_cart

function inside our carts module in lib/liveview_ecommerce/carts.ex so in the index.ex of our product_view , we need to alias this module to access this function , so at the top , also add

 alias  LiveviewEcommerce.Carts

Now our add_to_cart handle event function will be

def handle_event("add_to_cart", %{"id" => id}, socket) do
product = Products.get_product!(id)
user = socket.assigns.user

cart_params = %{
user_id: user.id,
product_id: product.id
}

Carts.create_cart(cart_params)

{:noreply, redirect(socket, to: Routes.cart_index_path(socket, :index))}
end

This function is taking the id of the product clicked and user id that we have assigned to our socket and using the create_cart function we have from our Carts module to create a new cart then later redirecting to the carts page .

When we click our add to cart button , we see this

We are taken to our carts page , which has a new record , to see our product details , we will have to preload the product and user , we would also want to see carts that only belong to us , for this we will create a new function in our carts module . Inside our carts module in lib/liveview_ecommerce/carts.ex

def list_carts_by_user(user_id) do
Repo.all(from(p in Cart, where: p.user_id == ^user_id, order_by: [asc: p.id]))
|> Repo.preload(:product)
|> Repo.preload(:user)
end

This function takes in a user_id as params and only shows carts where the cart.user_id matches the user_id params passed , it then preloads the product and the user .

Now if we go to our lib/liveview_ecomerce_web/live/cart_live/index.ex , we need to change some functions like mount and delete to ensure we are calling the list_carts_by_user function instead of list_carts , we will also be assigning the user to the socket as we did on the product_live/index.ex

We will first add

alias LiveviewEcommerce.Users

Then change the mount and handle_event delete to

def mount(_params, session, socket) do
user = Users.get_user_by_session_token(session["user_token"])
{:ok, socket |> assign(:carts, Carts.list_carts_by_user(user.id))}
end

and

def handle_event("delete", %{"id" => id}, socket) do
cart = Carts.get_cart!(id)
{:ok, _} = Carts.delete_cart(cart)

{:noreply, assign(socket, :carts, Carts.list_carts_by_user(socket.assigns.user.id))}
end

Now once the carts page mounts , we will only see cart items that belong to the user signed in and once a cart item is deleted , we see the cart items that belong to the user signed in as well .

Let us dispay Product details in our cart page .

We can go to lib/liveview_ecommerce_web/cart_live/index.html.heex and update that whole table to

<table>
<thead>
<tr>
<th>Product images</th>
<th>Product name</th>
<th>Product price</th>
<th>Quantity</th>


<th>Action</th>
</tr>
</thead>
<tbody id="carts">
<%= for cart <- @carts do %>
<tr id={"cart-#{cart.id}"}>
<td>
<%= for image <- cart.product.images do %>
<img src={image} style="width: 100px; height: 100px;" />
<% end %>
</td>

<td><%= cart.product.name %></td>
<td><%= cart.product.price %></td>
<td><%= cart.quantity %></td>

<td>
<span>
<%= link("Delete",
to: "#",
phx_click: "delete",
phx_value_id: cart.id,
data: [confirm: "Are you sure?"]
) %>
</span>
</td>
</tr>
<% end %>
</tbody>
</table>

We now have this

Good work , now let us add add and subtract buttons next to our quantity that will add the quantity or subtract the quanity of each item in our cart .

When we think about this , we will define phx_click events for this and in our index.ex define them , the add function should edit the cart item and change the quantity to quantity + 1 and the subtract should do the opposite .

Let us add these buttons next to our quantity field .

Edit the td with the cart.quantity to

 <td>
<%= link("+",
to: "#",
phx_click: "add",
phx_value_id: cart.id,
style:
" background-color: #1e90ff; color: white; padding: 2px 5px; border-radius: 5px;"
) %>

<%= cart.quantity %>

<%= link("-",
to: "#",
phx_click: "subtract",
phx_value_id: cart.id,
style: " background-color: red; color: white; padding: 2px 5px; border-radius: 5px;"
) %>
</td>

Now we have to define functions to handle these two click events , add and subtract , inside the index.ex for cart_live add these two


def handle_event("add", %{"id" => id}, socket) do
cart = Carts.get_cart!(id)

{:ok, _} = Carts.update_cart(cart, %{"quantity" => cart.quantity + 1})

{:noreply,
socket
|> assign(:carts, Carts.list_carts_by_user(socket.assigns.user.id))}
end

def handle_event("subtract", %{"id" => id}, socket) do
cart = Carts.get_cart!(id)

if cart.quantity > 1 do
{:ok, _} = Carts.update_cart(cart, %{"quantity" => cart.quantity - 1})
end

{:noreply,
socket
|> assign(:carts, Carts.list_carts_by_user(socket.assigns.user.id))}
end

Here , we are using the update_cart function in the Carts module to change the value of a column then rerendering the new carts .

Now the add and subtract Buttons should work , we can add a Total price column to our table that will be the multiplication of the cart.product.price and the cart.quantity values .

<th>Total</th>

<td><%= cart.quantity * cart.product.price %></td>

Congratulations , we have now have the add to cart functionality done in Phoenix Live View .

Implementing the “Add to Cart” functionality in your Phoenix LiveView e-commerce project is a crucial step towards providing a seamless shopping experience. By leveraging the power of LiveView, you can create a dynamic and real-time shopping cart. To dive deeper into the implementation details, check out the code on GitHub https://github.com/MICHAELMUNAVU83/liveview_ecommerce. Enhance your e-commerce platform with features like cart persistence, checkout processes, and order management, and deliver an exceptional shopping experience to your users. Happy coding!

--

--