2026-02-05 22:11:16 +00:00
|
|
|
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
|
add denormalized product fields and use Product structs throughout
Adds cheapest_price, compare_at_price, in_stock, on_sale columns to
products table (recomputed from variants after each sync). Shop
components now work with Product structs directly instead of plain
maps from PreviewData. Renames .name to .title, adds Product display
helpers (primary_image, hover_image, option_types) and ProductImage
helpers (display_url, direct_url, source_width). Adds Products context
query functions for storefront use (list_visible_products,
get_visible_product, list_categories with DB-level sort/filter).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:26:39 +00:00
|
|
|
alias SimpleshopTheme.Products.{Product, ProductImage}
|
2026-02-05 22:11:16 +00:00
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
2026-02-09 08:20:59 +00:00
|
|
|
# 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: %{}
|
|
|
|
|
|
2026-02-05 22:11:16 +00:00
|
|
|
cart_items
|
|
|
|
|
|> Enum.map(fn {variant_id, quantity} ->
|
|
|
|
|
case Map.get(variants_map, variant_id) do
|
|
|
|
|
nil ->
|
2026-02-09 08:20:59 +00:00
|
|
|
case Map.get(mock_map, variant_id) do
|
|
|
|
|
nil -> nil
|
|
|
|
|
item -> %{item | quantity: quantity}
|
|
|
|
|
end
|
2026-02-05 22:11:16 +00:00
|
|
|
|
|
|
|
|
variant ->
|
|
|
|
|
%{
|
|
|
|
|
variant_id: variant.id,
|
2026-02-06 23:33:22 +00:00
|
|
|
product_id: variant.product.id,
|
2026-02-05 22:11:16 +00:00
|
|
|
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
|
|
|
|
|
|
2026-02-09 08:20:59 +00:00
|
|
|
# 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 ->
|
add denormalized product fields and use Product structs throughout
Adds cheapest_price, compare_at_price, in_stock, on_sale columns to
products table (recomputed from variants after each sync). Shop
components now work with Product structs directly instead of plain
maps from PreviewData. Renames .name to .title, adds Product display
helpers (primary_image, hover_image, option_types) and ProductImage
helpers (display_url, direct_url, source_width). Adds Products context
query functions for storefront use (list_visible_products,
get_visible_product, list_categories with DB-level sort/filter).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:26:39 +00:00
|
|
|
image = ProductImage.direct_url(Product.primary_image(product), 400)
|
2026-02-09 08:20:59 +00:00
|
|
|
|
|
|
|
|
{v.id,
|
|
|
|
|
%{
|
|
|
|
|
variant_id: v.id,
|
add denormalized product fields and use Product structs throughout
Adds cheapest_price, compare_at_price, in_stock, on_sale columns to
products table (recomputed from variants after each sync). Shop
components now work with Product structs directly instead of plain
maps from PreviewData. Renames .name to .title, adds Product display
helpers (primary_image, hover_image, option_types) and ProductImage
helpers (display_url, direct_url, source_width). Adds Products context
query functions for storefront use (list_visible_products,
get_visible_product, list_categories with DB-level sort/filter).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:26:39 +00:00
|
|
|
product_id: product[:id],
|
|
|
|
|
name: product.title,
|
2026-02-09 08:20:59 +00:00
|
|
|
variant: format_variant_options(v.options),
|
|
|
|
|
price: v.price,
|
|
|
|
|
quantity: 1,
|
|
|
|
|
image: image
|
|
|
|
|
}}
|
|
|
|
|
end)
|
|
|
|
|
end)
|
|
|
|
|
|> Map.new()
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-05 22:11:16 +00:00
|
|
|
# =============================================================================
|
|
|
|
|
# Helpers
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
2026-02-08 12:24:39 +00:00
|
|
|
@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
|
|
|
|
|
|
2026-02-05 22:11:16 +00:00
|
|
|
@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
|