Real-Time Phoenix: Cart Add not Working (page 178)

I’ve been working through the book, and I’ve gotten up to the point where I need to test adding items to my cart. However, when I click on any of the size buttons, it takes me to an error page where it says “no route found for POST /cart/add (Sneakers23Web.Router)”.

I checked the router.ex file under sneakers_23_web and there’s no entry for the /cart/add path, and there isn’t a CartController.ex file at all either so I’m not sure where to go from here.

Hi, thanks for reading Real-Time Phoenix. Sorry you ran into this issue.

The cart add function is handled via JavaScript at this code. This is added to the project on page 176.

If you added this JavaScript and you’re not seeing it take effect in the UI, you may need to check that the JS watcher was able to build your project, and then refresh.

Thank you for the quick reply! I checked my JS code, and it did get built. However, I’m not seeing either the “Cart received” or “Join failed” messages in my console even though my product_socket WebSocket messages get a successful join response and an empty items array.

Here is my ShoppingCartChannel and app/dom.js

ShoppingCartChannel.ex

defmodule Sneakers23Web.ShoppingCartChannel do
  use Phoenix.Channel
  alias Sneakers23.Checkout
  import Sneakers23Web.CartView, only: [cart_to_map: 1]
  intercept ["cart_updated"]

  def join("cart:" <> id, params, socket) when byte_size(id) == 64 do
    cart = get_cart(params)
    socket = assign(socket, :cart, cart)
    send(self(), :send_cart)
    enqueue_cart_subscriptions(cart)

    {:ok, socket}
  end

  def handle_info(:send_cart, socket = %{assigns: %{cart: cart}}) do
    push(socket, "cart", cart_to_map(cart))
    {:noreply, socket}
  end

  def handle_info({:subscribe, item_id}, socket) do
    Phoenix.PubSub.subscribe(Sneakers23.PubSub, "item_out:#{item_id}")
    {:noreply, socket}
  end

  def handle_info({:unsubscribe, item_id}, socket) do
    Phoenix.PubSub.unsubscribe(Sneakers23.PubSub, "item_out:#{item_id}")
    {:noreply, socket}
  end

  def handle_info({:item_out, _id}, socket = %{assigns: %{cart: cart}}) do
    push(socket, "cart", cart_to_map(cart))
    {:noreply, socket}
  end

  def handle_in("add_item", %{"item_id" => id}, socket = %{assigns: %{cart: cart}}) do
    case Checkout.add_item_to_cart(cart, String.to_integer(id)) do
      {:ok, new_cart} ->
        send(self(), {:subscribe, id})
        broadcast_cart(new_cart, socket, added: [id])
        socket = assign(socket, :cart, new_cart)
        {:reply, {:ok, cart_to_map(new_cart)}, socket}

      {:error, :duplicate_item} ->
        {:reply, {:error, %{error: "duplicate_item"}}, socket}
    end
  end

  def handle_in("remove_item", %{"item_id" => id}, socket = %{assigns: %{cart: cart}}) do
    case Checkout.remove_item_from_cart(cart, String.to_integer(id)) do
      {:ok, new_cart} ->
        send(self(), {:unsubscribe, id})
        broadcast_cart(new_cart, socket, removed: [id])
        socket = assign(socket, :cart, new_cart)
        {:reply, {:ok, cart_to_map(new_cart)}, socket}

      {:error, :not_found} ->
        {:reply, {:error, %{error: "not_found"}, socket}}
    end
  end
  
  def handle_out("cart_updated", params, socket) do
    modify_subscriptions(params)
    cart = get_cart(params)
    socket = assign(socket, :cart, cart)
    push(socket, "cart", cart_to_map(cart))

    {:noreply, socket}
  end
  
  defp get_cart(params) do
    params
    |> Map.get("serialized", nil)
    |> Checkout.restore_cart()
  end
  
  defp broadcast_cart(cart, socket, opts) do
    {:ok, serialized} = Checkout.export_cart(cart)

    broadcast_from(socket, "cart_updated", %{
      "serialized" => serialized,
      "added" => Keyword.get(opts, :added, []),
      "removed" => Keyword.get(opts, :removed, [])
    })
  end
  
  defp enqueue_cart_subscriptions(cart) do
    cart
    |> Checkout.cart_item_ids()
    |> Enum.each(fn id ->
      send(self(), {:subscribe, id})
    end)
  end
  
  defp modify_subscriptions(%{"added" => add, "removed" => remove}) do
    Enum.each(add, &send(self(), {:subscribe, &1}))
    Enum.each(remove, &send(self(), {:unsubscribe, &1}))
  end
end

app.js

import css from "../css/app.css"
import { productSocket } from "./socket"
import dom from "./dom"
import Cart from "./cart"

productSocket.connect()

const productIds = dom.getProductIds()

productIds.forEach((id) => setupProductChannel(productSocket, id))

const cartChannel = Cart.setupCartChannel(productSocket, window.cartId, {
    onCartChange: (newCart) => {
        dom.renderCartHtml(newCart)
    }
})

dom.onItemClick((itemId) => {
    Cart.addCartItem(cartChannel, itemId)
})

dom.onItemRemoveClick((itemId) => {
    Cart.removeCartItem(cartChannel, itemId)
})

function setupProductChannel(socket, productId) {
    const productChannel = socket.channel(`product:${productId}`)
    productChannel.join()
        .receive("error", () => {
            console.error("channel join failed")
        })

    productChannel.on('released', ({size_html}) => {
        dom.replaceProductComingSoon(productId, size_html)
    })

    productChannel.on('stock_change', ({product_id, item_id, level}) => {
        dom.updateItemLevel(item_id, level)
    })
}

dom.js

import { getCartHtml } from "./cartRenderer";

const dom = {}

dom.renderCartHtml = (cart) => {
    const cartContainer = document.getElementById("cart-container")
    cartContainer.innerHTML = getCartHtml(cart)
}

function getProductIds() {
    const products = document.querySelectorAll('.product-listing')
    return Array.from(products).map((el) => el.dataset.productId)
}

dom.getProductIds = getProductIds

function replaceProductComingSoon(productId, sizeHtml) {
    const name = `.product-soon-${productId}`
    const productSoonEls = document.querySelectorAll(name)

    productSoonEls.forEach((el) => {
        const fragment = document.createRange()
            .createContextualFragment(sizeHtml)
        el.replaceWith(fragment)
    })
}

dom.replaceProductComingSoon = replaceProductComingSoon

function updateItemLevel(itemId, level) {
    Array.from(document.querySelectorAll(`.size-container__entry`))
        .filter((el) => el.value == itemId)
        .forEach((el) => {
            removeStockLevelClasses(el)
            el.classList.add(`size-container__entry--level-${level}`)
            el.disabled = level === "out"
        })
}

dom.updateItemLevel = updateItemLevel

function removeStockLevelClasses(el) {
    Array.from(el.classList)
        .filter((s) => s.startsWith("size-container__entry-level-"))
        .forEach((name) => el.classList.remove(name))
}

dom.onItemClick = (fn) => {
    document.addEventListener("click", (event) => {
        if (!event.target.matches(".size-container-entry")) { return }
        event.preventDefault()

        fn(event.target.value)
    })
}

dom.onItemRemoveClick = (fn) => {
    document.addEventListener("click", (event) => {
        if (!event.target.matches(".cart-item__remove")) {return}
        event.preventDefault()

        fn(event.target.dataset.itemId)
    })
}

export default dom

It’s hard for me to say for certain what’s going on. However, your app submitting the form should be impossible due to the event override with prevent default.

This tells me that the js is likely either not building or not loading. I recommend adding console logs and seeing if they display correctly.

You’d need to upload the whole project onto GitHub for me to have a chance of telling you what in your code is wrong. However, I recommend you work through it because it will probably be educational to debug.