simpleshop_theme/lib/simpleshop_theme/cart.ex
jamey 90b0242a06 fix cart hydration for demo mode with mock products
Cart.hydrate/1 now falls back to PreviewData mock products when variant
IDs aren't found in the database, so add-to-cart works on fresh deploys
without synced Printify data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 08:20:59 +00:00

290 lines
7.7 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)
# Fall back to mock data for variant IDs not found in DB (demo mode)
missing_ids = variant_ids -- Map.keys(variants_map)
mock_map = if missing_ids != [], do: mock_variants_map(missing_ids), else: %{}
cart_items
|> Enum.map(fn {variant_id, quantity} ->
case Map.get(variants_map, variant_id) do
nil ->
case Map.get(mock_map, variant_id) do
nil -> nil
item -> %{item | quantity: quantity}
end
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
# Build a lookup map from mock product data for variant IDs not in the DB.
# Allows the cart to work in demo mode when no real products are synced.
defp mock_variants_map(variant_ids) do
ids_set = MapSet.new(variant_ids)
SimpleshopTheme.Theme.PreviewData.products()
|> Enum.flat_map(fn product ->
(product[:variants] || [])
|> Enum.filter(fn v -> MapSet.member?(ids_set, v.id) end)
|> Enum.map(fn v ->
image =
case product[:image_url] do
"/mockups/" <> _ = url -> "#{url}-400.webp"
url -> url
end
{v.id,
%{
variant_id: v.id,
product_id: product.id,
name: product.name,
variant: format_variant_options(v.options),
price: v.price,
quantity: 1,
image: image
}}
end)
end)
|> Map.new()
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