berrypod/lib/berrypod_web/cart_hook.ex
jamey 67a26eb6b4
All checks were successful
deploy / deploy (push) Successful in 1m26s
add contextual prompts for skipped setup steps
Disable checkout when Stripe isn't connected (cart drawer, cart page,
and early guard in checkout controller to prevent orphaned orders).
Show amber warning on order detail when email isn't configured.
Fix pre-existing missing vertical spacing between page blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:02:49 +00:00

195 lines
5.8 KiB
Elixir

defmodule BerrypodWeb.CartHook do
@moduledoc """
LiveView on_mount hook for cart state and shared event handling.
Mounted in the public_shop live_session to give all shop LiveViews
cart state, PubSub sync, and shared event handlers via attach_hook.
Handles these events so individual LiveViews don't have to:
- `open_cart_drawer` / `close_cart_drawer` - toggle drawer visibility
- `remove_item` - remove item from cart
- `increment` / `decrement` - change item quantity
- `change_country` - update shipping country
- `{:cart_updated, cart}` info - cross-tab cart sync via PubSub
LiveViews with custom cart logic (e.g. add_to_cart) can call
`update_cart_assigns/2` and `broadcast_and_update/2` directly.
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1, push_event: 3]
alias Berrypod.{Cart, Settings, Shipping}
def on_mount(:mount_cart, _params, session, socket) do
cart_items = Cart.get_from_session(session)
country_code = session["country_code"] || "GB"
available_countries = Shipping.list_available_countries_with_names()
socket =
socket
|> assign(:country_code, country_code)
|> assign(:available_countries, available_countries)
|> update_cart_assigns(cart_items)
|> assign(:cart_drawer_open, false)
|> assign(:cart_status, nil)
|> assign(:stripe_connected, Settings.has_secret?("stripe_api_key"))
|> attach_hook(:cart_events, :handle_event, &handle_cart_event/3)
|> attach_hook(:cart_info, :handle_info, &handle_cart_info/2)
socket =
if connected?(socket) do
csrf_token = Map.get(session, "_csrf_token", "default")
topic = "cart:#{csrf_token}"
Phoenix.PubSub.subscribe(Berrypod.PubSub, topic)
assign(socket, :cart_topic, topic)
else
assign(socket, :cart_topic, nil)
end
{:cont, socket}
end
# Shared event handlers
defp handle_cart_event("open_cart_drawer", _params, socket) do
{:halt, assign(socket, :cart_drawer_open, true)}
end
defp handle_cart_event("close_cart_drawer", _params, socket) do
{:halt, assign(socket, :cart_drawer_open, false)}
end
defp handle_cart_event("change_country", %{"country" => code}, socket) do
socket =
socket
|> assign(:country_code, code)
|> update_cart_assigns(socket.assigns.raw_cart)
|> push_event("persist_country", %{code: code})
{:halt, socket}
end
defp handle_cart_event("remove_item", %{"id" => variant_id}, socket) do
cart = Cart.remove_item(socket.assigns.raw_cart, variant_id)
socket =
socket
|> broadcast_and_update(cart)
|> assign(:cart_status, "Item removed from cart")
{:halt, socket}
end
defp handle_cart_event("increment", %{"id" => variant_id}, socket) do
cart = Cart.add_item(socket.assigns.raw_cart, variant_id, 1)
new_qty = Cart.get_quantity(cart, variant_id)
socket =
socket
|> broadcast_and_update(cart)
|> assign(:cart_status, "Quantity updated to #{new_qty}")
{:halt, socket}
end
defp handle_cart_event("decrement", %{"id" => variant_id}, socket) do
current = Cart.get_quantity(socket.assigns.raw_cart, variant_id)
cart = Cart.update_quantity(socket.assigns.raw_cart, variant_id, current - 1)
new_qty = Cart.get_quantity(cart, variant_id)
socket =
socket
|> broadcast_and_update(cart)
|> assign(:cart_status, "Quantity updated to #{new_qty}")
{:halt, socket}
end
defp handle_cart_event(
"update_quantity_form",
%{"variant_id" => id, "quantity" => qty_str},
socket
) do
quantity = String.to_integer(qty_str)
cart = Cart.update_quantity(socket.assigns.raw_cart, id, quantity)
new_qty = Cart.get_quantity(cart, id)
socket =
socket
|> broadcast_and_update(cart)
|> assign(:cart_status, "Quantity updated to #{new_qty}")
{:halt, socket}
end
defp handle_cart_event("remove_item_form", %{"variant_id" => id}, socket) do
cart = Cart.remove_item(socket.assigns.raw_cart, id)
socket =
socket
|> broadcast_and_update(cart)
|> assign(:cart_status, "Item removed from cart")
{:halt, socket}
end
defp handle_cart_event(_event, _params, socket), do: {:cont, socket}
# Shared info handlers
defp handle_cart_info({:cart_updated, cart}, socket) do
{:halt, update_cart_assigns(socket, cart)}
end
defp handle_cart_info(_msg, socket), do: {:cont, socket}
# Public helpers for LiveViews with custom cart logic
@doc """
Updates all cart-related assigns from raw cart data.
"""
def update_cart_assigns(socket, cart) do
%{items: items, count: count, subtotal: subtotal} = Cart.build_state(cart)
country_code = socket.assigns[:country_code] || "GB"
subtotal_pence = Cart.calculate_subtotal(items)
shipping_estimate =
case Shipping.calculate_for_cart(items, country_code) do
{:ok, cost} when cost > 0 -> cost
_ -> nil
end
cart_total =
Cart.format_price(subtotal_pence + (shipping_estimate || 0))
socket
|> assign(:raw_cart, cart)
|> assign(:cart_items, items)
|> assign(:cart_count, count)
|> assign(:cart_subtotal, subtotal)
|> assign(:cart_total, cart_total)
|> assign(:shipping_estimate, shipping_estimate)
end
@doc """
Broadcasts cart update to other tabs and updates local assigns.
Uses broadcast_from to avoid notifying self (prevents double-update).
"""
def broadcast_and_update(socket, cart) do
if socket.assigns.cart_topic do
Phoenix.PubSub.broadcast_from(
Berrypod.PubSub,
self(),
socket.assigns.cart_topic,
{:cart_updated, cart}
)
end
socket
|> update_cart_assigns(cart)
|> push_event("persist_cart", %{items: Cart.serialize(cart)})
end
end