feat: add cart page, cart drawer, and shared cart infrastructure

- Cart context with pure functions for add/remove/update/hydrate
- Price formatting via ex_money (replaces all float division)
- CartHook on_mount with attach_hook for shared event handlers
  (open/close drawer, remove item, PubSub sync)
- Accessible cart drawer with focus trap, scroll lock, aria-live
- Cart page with increment/decrement quantity controls
- Preview mode cart drawer support in theme editor
- Cart persistence to session via JS hook + API endpoint
- 19 tests covering all Cart pure functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-05 22:11:16 +00:00
parent 880e7a2888
commit 1bc08bfb23
27 changed files with 1163 additions and 155 deletions

View File

@@ -31,9 +31,6 @@ defmodule SimpleshopThemeWeb.ShopLive.About do
|> assign(:logo_image, logo_image)
|> assign(:header_image, header_image)
|> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
{:ok, socket}
end
@@ -49,6 +46,8 @@ defmodule SimpleshopThemeWeb.ShopLive.About do
cart_items={@cart_items}
cart_count={@cart_count}
cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
/>
"""
end

View File

@@ -1,9 +1,10 @@
defmodule SimpleshopThemeWeb.ShopLive.Cart do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Cart
alias SimpleshopTheme.Settings
alias SimpleshopTheme.Media
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator, PreviewData}
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator}
@impl true
def mount(_params, _session, socket) do
@@ -23,15 +24,6 @@ defmodule SimpleshopThemeWeb.ShopLive.Cart do
logo_image = Media.get_logo()
header_image = Media.get_header()
# For now, use preview data for cart items
# In a real implementation, this would come from session/database
cart_page_items = PreviewData.cart_items()
cart_page_subtotal =
Enum.reduce(cart_page_items, 0, fn item, acc ->
acc + item.product.price * item.quantity
end)
socket =
socket
|> assign(:page_title, "Cart")
@@ -39,34 +31,56 @@ defmodule SimpleshopThemeWeb.ShopLive.Cart do
|> assign(:generated_css, generated_css)
|> assign(:logo_image, logo_image)
|> assign(:header_image, header_image)
|> assign(:cart_page_items, cart_page_items)
|> assign(:cart_page_subtotal, cart_page_subtotal)
|> assign(:mode, :shop)
|> assign(:cart_items, PreviewData.cart_drawer_items())
|> assign(:cart_count, length(cart_page_items))
|> assign(:cart_subtotal, format_subtotal(cart_page_subtotal))
{:ok, socket}
end
@impl true
def handle_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
|> SimpleshopThemeWeb.CartHook.broadcast_and_update(cart)
|> assign(:cart_status, "Quantity updated to #{new_qty}")
{:noreply, socket}
end
@impl true
def handle_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
|> SimpleshopThemeWeb.CartHook.broadcast_and_update(cart)
|> assign(:cart_status, "Quantity updated to #{new_qty}")
{:noreply, socket}
end
@impl true
def render(assigns) do
cart_page_subtotal = Cart.calculate_subtotal(assigns.cart_items)
assigns = assign(assigns, :cart_page_subtotal, cart_page_subtotal)
~H"""
<SimpleshopThemeWeb.PageTemplates.cart
theme_settings={@theme_settings}
logo_image={@logo_image}
header_image={@header_image}
cart_page_items={@cart_page_items}
cart_items={@cart_items}
cart_page_subtotal={@cart_page_subtotal}
mode={@mode}
cart_items={@cart_items}
cart_count={@cart_count}
cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
/>
"""
end
defp format_subtotal(subtotal_pence) do
"£#{Float.round(subtotal_pence / 100, 2)}"
end
end

View File

@@ -39,9 +39,6 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do
|> assign(:logo_image, logo_image)
|> assign(:header_image, header_image)
|> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
|> assign(:categories, PreviewData.categories())
|> assign(:sort_options, @sort_options)
|> assign(:current_sort, "featured")
@@ -105,6 +102,8 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do
def render(assigns) do
~H"""
<div
id="shop-container"
phx-hook="CartPersist"
class="shop-container min-h-screen pb-20 md:pb-0"
style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"
>
@@ -169,7 +168,10 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do
<SimpleshopThemeWeb.ShopComponents.cart_drawer
cart_items={@cart_items}
subtotal={@cart_subtotal}
cart_count={@cart_count}
mode={@mode}
open={@cart_drawer_open}
cart_status={assigns[:cart_status]}
/>
<SimpleshopThemeWeb.ShopComponents.search_modal hint_text={

View File

@@ -31,9 +31,6 @@ defmodule SimpleshopThemeWeb.ShopLive.Contact do
|> assign(:logo_image, logo_image)
|> assign(:header_image, header_image)
|> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
{:ok, socket}
end
@@ -49,6 +46,8 @@ defmodule SimpleshopThemeWeb.ShopLive.Contact do
cart_items={@cart_items}
cart_count={@cart_count}
cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
/>
"""
end

View File

@@ -37,9 +37,6 @@ defmodule SimpleshopThemeWeb.ShopLive.Home do
|> assign(:header_image, header_image)
|> assign(:preview_data, preview_data)
|> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
{:ok, socket}
end
@@ -56,6 +53,8 @@ defmodule SimpleshopThemeWeb.ShopLive.Home do
cart_items={@cart_items}
cart_count={@cart_count}
cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
/>
"""
end

View File

@@ -1,6 +1,7 @@
defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Cart
alias SimpleshopTheme.Settings
alias SimpleshopTheme.Media
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator, PreviewData}
@@ -61,9 +62,6 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
|> assign(:related_products, related_products)
|> assign(:quantity, 1)
|> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
|> assign(:option_types, option_types)
|> assign(:variants, variants)
|> assign(:selected_options, selected_options)
@@ -153,6 +151,25 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
{:noreply, socket}
end
@impl true
def handle_event("add_to_cart", _params, socket) do
variant = socket.assigns.selected_variant
if variant do
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
socket =
socket
|> SimpleshopThemeWeb.CartHook.broadcast_and_update(cart)
|> assign(:cart_drawer_open, true)
|> assign(:cart_status, "#{socket.assigns.product.name} added to cart")
{:noreply, socket}
else
{:noreply, socket}
end
end
@impl true
def render(assigns) do
~H"""
@@ -168,6 +185,8 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
cart_items={@cart_items}
cart_count={@cart_count}
cart_subtotal={@cart_subtotal}
cart_drawer_open={@cart_drawer_open}
cart_status={@cart_status}
option_types={@option_types}
selected_options={@selected_options}
available_options={@available_options}