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>
252 lines
6.5 KiB
Elixir
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
|