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>
290 lines
7.7 KiB
Elixir
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
|