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