berrypod/lib/simpleshop_theme/cart.ex
jamey 57c3ba0e28 wire shop LiveViews to DB queries and improve search UX
Replace PreviewData indirection in all shop LiveViews with direct
Products context queries. Home, collection, product detail and error
pages now query the database. Categories loaded once in ThemeHook.
Cart hydration no longer falls back to mock data. PreviewData kept
only for the theme editor.

Search modal gains keyboard navigation (arrow keys, Enter, Escape),
Cmd+K/Ctrl+K shortcut, full ARIA combobox pattern, LiveView navigate
links, and 150ms debounce. SearchModal JS hook manages selection
state and highlight. search.ex gets transaction safety on reindex
and a public remove_product/1. 10 new integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 08:27:26 +00:00

252 lines
6.5 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,
product_id: variant.product.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 """
Builds the full display state for a cart.
Takes raw cart items (list of {variant_id, quantity} tuples) and returns
a map with hydrated items, count, and formatted subtotal. Single source
of truth for cart state computation — used by CartHook.
"""
def build_state(raw_cart) do
hydrated = hydrate(raw_cart)
%{
items: hydrated,
count: item_count(raw_cart),
subtotal: format_subtotal(hydrated)
}
end
@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