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:
233
lib/simpleshop_theme/cart.ex
Normal file
233
lib/simpleshop_theme/cart.ex
Normal file
@@ -0,0 +1,233 @@
|
||||
defmodule SimpleshopTheme.Cart do
|
||||
@moduledoc """
|
||||
The Cart context.
|
||||
|
||||
Manages shopping cart operations stored in session. Cart items are stored
|
||||
as a list of {variant_id, quantity} tuples for minimal session storage.
|
||||
Items are hydrated with full product/variant data when needed for display.
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Products
|
||||
|
||||
@session_key "cart"
|
||||
|
||||
# =============================================================================
|
||||
# Session Operations
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Gets cart items from session.
|
||||
|
||||
Returns a list of {variant_id, quantity} tuples.
|
||||
"""
|
||||
def get_from_session(session) do
|
||||
case Map.get(session, @session_key) do
|
||||
nil -> []
|
||||
items when is_list(items) -> items
|
||||
_ -> []
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Puts cart items in session via Plug.Conn.
|
||||
|
||||
Used by the CartController to persist cart to session cookie.
|
||||
"""
|
||||
def put_in_session(conn, cart_items) do
|
||||
Plug.Conn.put_session(conn, @session_key, cart_items)
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Cart Operations
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Adds an item to the cart.
|
||||
|
||||
If the variant is already in the cart, increments the quantity.
|
||||
Returns the updated cart items list.
|
||||
"""
|
||||
def add_item(cart_items, variant_id, quantity \\ 1)
|
||||
when is_integer(quantity) and quantity > 0 do
|
||||
case List.keyfind(cart_items, variant_id, 0) do
|
||||
nil ->
|
||||
cart_items ++ [{variant_id, quantity}]
|
||||
|
||||
{^variant_id, existing_qty} ->
|
||||
List.keyreplace(cart_items, variant_id, 0, {variant_id, existing_qty + quantity})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the quantity of an item in the cart.
|
||||
|
||||
If quantity is 0 or less, removes the item.
|
||||
Returns the updated cart items list.
|
||||
"""
|
||||
def update_quantity(cart_items, variant_id, quantity) when is_integer(quantity) do
|
||||
if quantity <= 0 do
|
||||
remove_item(cart_items, variant_id)
|
||||
else
|
||||
case List.keyfind(cart_items, variant_id, 0) do
|
||||
nil ->
|
||||
cart_items
|
||||
|
||||
{^variant_id, _} ->
|
||||
List.keyreplace(cart_items, variant_id, 0, {variant_id, quantity})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes an item from the cart.
|
||||
|
||||
Returns the updated cart items list.
|
||||
"""
|
||||
def remove_item(cart_items, variant_id) do
|
||||
List.keydelete(cart_items, variant_id, 0)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the quantity of a specific variant in the cart.
|
||||
|
||||
Returns 0 if not found.
|
||||
"""
|
||||
def get_quantity(cart_items, variant_id) do
|
||||
case List.keyfind(cart_items, variant_id, 0) do
|
||||
nil -> 0
|
||||
{_, qty} -> qty
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Hydration
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Hydrates cart items with full variant and product data.
|
||||
|
||||
Takes a list of {variant_id, quantity} tuples and returns a list of maps
|
||||
with full display data including product name, variant options, price, and image.
|
||||
"""
|
||||
def hydrate(cart_items) when is_list(cart_items) do
|
||||
variant_ids = Enum.map(cart_items, fn {id, _qty} -> id end)
|
||||
|
||||
if variant_ids == [] do
|
||||
[]
|
||||
else
|
||||
variants_map = Products.get_variants_with_products(variant_ids)
|
||||
|
||||
cart_items
|
||||
|> Enum.map(fn {variant_id, quantity} ->
|
||||
case Map.get(variants_map, variant_id) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
variant ->
|
||||
%{
|
||||
variant_id: variant.id,
|
||||
name: variant.product.title,
|
||||
variant: format_variant_options(variant.options),
|
||||
price: variant.price,
|
||||
quantity: quantity,
|
||||
image: variant_image_url(variant.product)
|
||||
}
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
end
|
||||
|
||||
defp format_variant_options(options) when is_map(options) and map_size(options) > 0 do
|
||||
options
|
||||
|> Map.values()
|
||||
|> Enum.join(" / ")
|
||||
end
|
||||
|
||||
defp format_variant_options(_), do: nil
|
||||
|
||||
defp variant_image_url(product) do
|
||||
# Get first image from preloaded images
|
||||
case product.images do
|
||||
[first | _] ->
|
||||
if first.image_id do
|
||||
"/images/#{first.image_id}/variant/400.webp"
|
||||
else
|
||||
first.src
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Returns the total item count in the cart.
|
||||
"""
|
||||
def item_count(cart_items) do
|
||||
Enum.reduce(cart_items, 0, fn {_, qty}, acc -> acc + qty end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the subtotal from hydrated cart items.
|
||||
|
||||
Returns the total in pence.
|
||||
"""
|
||||
def calculate_subtotal(hydrated_items) do
|
||||
Enum.reduce(hydrated_items, 0, fn item, acc ->
|
||||
acc + item.price * item.quantity
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats a price in pence as a currency string using ex_money.
|
||||
"""
|
||||
def format_price(price_pence) when is_integer(price_pence) do
|
||||
price_pence
|
||||
|> Decimal.new()
|
||||
|> Decimal.div(100)
|
||||
|> then(&Money.new(:GBP, &1))
|
||||
|> Money.to_string!()
|
||||
end
|
||||
|
||||
def format_price(_), do: format_price(0)
|
||||
|
||||
@doc """
|
||||
Formats the subtotal from hydrated items as a GBP string.
|
||||
"""
|
||||
def format_subtotal(hydrated_items) do
|
||||
hydrated_items
|
||||
|> calculate_subtotal()
|
||||
|> format_price()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Serializes cart items for JSON transport.
|
||||
|
||||
Converts {variant_id, quantity} tuples to [variant_id, quantity] lists
|
||||
for JSON compatibility.
|
||||
"""
|
||||
def serialize(cart_items) do
|
||||
Enum.map(cart_items, fn {id, qty} -> [id, qty] end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deserializes cart items from JSON transport.
|
||||
|
||||
Converts [variant_id, quantity] lists back to {variant_id, quantity} tuples.
|
||||
"""
|
||||
def deserialize(items) when is_list(items) do
|
||||
items
|
||||
|> Enum.map(fn
|
||||
[id, qty] when is_binary(id) and is_integer(qty) -> {id, qty}
|
||||
_ -> nil
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
def deserialize(_), do: []
|
||||
end
|
||||
@@ -411,6 +411,21 @@ defmodule SimpleshopTheme.Products do
|
||||
# Product Variants
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Gets multiple variants by their IDs with associated products and images.
|
||||
|
||||
Returns a map of variant_id => variant struct for efficient lookup.
|
||||
Used by Cart.hydrate/1 to fetch variant data for display.
|
||||
"""
|
||||
def get_variants_with_products(variant_ids) when is_list(variant_ids) do
|
||||
from(v in ProductVariant,
|
||||
where: v.id in ^variant_ids,
|
||||
preload: [product: [images: :image]]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Map.new(&{&1.id, &1})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a product variant.
|
||||
"""
|
||||
|
||||
@@ -32,19 +32,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
||||
|
||||
@doc """
|
||||
Returns cart drawer items formatted for the cart drawer component.
|
||||
|
||||
Format matches Cart.hydrate/1 output for consistency between preview and live modes.
|
||||
"""
|
||||
def cart_drawer_items do
|
||||
[
|
||||
%{
|
||||
variant_id: "preview-1",
|
||||
name: "Mountain Sunrise Art Print",
|
||||
variant: "12″ x 18″ / Matte",
|
||||
price: "£24.00",
|
||||
price: 2400,
|
||||
quantity: 1,
|
||||
image: "/mockups/mountain-sunrise-print-1.jpg"
|
||||
},
|
||||
%{
|
||||
variant_id: "preview-2",
|
||||
name: "Fern Leaf Mug",
|
||||
variant: "11oz / White",
|
||||
price: "£14.99",
|
||||
price: 1499,
|
||||
quantity: 2,
|
||||
image: "/mockups/fern-leaf-mug-1.jpg"
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user