- 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>
234 lines
6.0 KiB
Elixir
234 lines
6.0 KiB
Elixir
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
|