berrypod/lib/simpleshop_theme_web/cart_hook.ex
jamey 5c2f70ce44 add shipping costs with live exchange rates and country detection
Shipping rates fetched from Printify during product sync, converted to
GBP at sync time using frankfurter.app ECB exchange rates with 5%
buffer. Cached in shipping_rates table per blueprint/provider/country.

Cart page shows shipping estimate with country selector (detected from
Accept-Language header, persisted in cookie). Stripe Checkout includes
shipping_options for UK domestic and international delivery. Order
shipping_cost extracted from Stripe on payment.

ScheduledSyncWorker runs every 6 hours via Oban cron to keep rates
and exchange rates fresh. REST_OF_THE_WORLD fallback covers unlisted
countries. 780 tests, 0 failures.

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

167 lines
5.0 KiB
Elixir

defmodule SimpleshopThemeWeb.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 SimpleshopTheme.Cart
alias SimpleshopTheme.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)
|> 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(SimpleshopTheme.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(_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(
SimpleshopTheme.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