Packages
phoenix_kit
1.7.30
1.7.210
1.7.209
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/shop/web/cart_page.ex
defmodule PhoenixKit.Modules.Shop.Web.CartPage do
@moduledoc """
Public cart page LiveView for E-Commerce module.
Supports real-time cart synchronization across multiple browser tabs
via PubSub subscription. When cart is updated in one tab, all other
tabs receive the update automatically.
"""
use PhoenixKitWeb, :live_view
alias PhoenixKit.Modules.Billing.Currency
alias PhoenixKit.Modules.Shop
alias PhoenixKit.Modules.Shop.Events
alias PhoenixKit.Modules.Shop.ShippingMethod
alias PhoenixKit.Modules.Shop.Translations
@impl true
def mount(_params, session, socket) do
# Get session_id from session (for guest users)
session_id = session["shop_session_id"] || generate_session_id()
# Get current language for localized URLs
current_language = socket.assigns[:current_locale] || Translations.default_language()
# Get current user if logged in
user = get_current_user(socket)
user_id = if user, do: user.id, else: nil
# Get or create cart
{:ok, cart} = Shop.get_or_create_cart(user_id: user_id, session_id: session_id)
# Subscribe to cart events for real-time sync across tabs
if connected?(socket) do
Events.subscribe_to_cart(cart)
end
# Get available shipping methods
shipping_methods = Shop.get_available_shipping_methods(cart)
# Auto-select cheapest shipping method if none selected
{:ok, cart} = Shop.auto_select_shipping_method(cart, shipping_methods)
# Get default currency from Billing
currency = Shop.get_default_currency()
# Check if user is authenticated
authenticated = not is_nil(socket.assigns[:phoenix_kit_current_user])
socket =
socket
|> assign(:page_title, "Shopping Cart")
|> assign(:cart, cart)
|> assign(:session_id, session_id)
|> assign(:shipping_methods, shipping_methods)
|> assign(:currency, currency)
|> assign(:authenticated, authenticated)
|> assign(:current_language, current_language)
{:ok, socket}
end
@impl true
def handle_event("update_quantity", %{"item_id" => item_id, "quantity" => quantity}, socket) do
item_id = String.to_integer(item_id)
quantity = max(1, String.to_integer(quantity))
update_item_quantity(socket, item_id, quantity)
end
@impl true
def handle_event("remove_item", %{"item_id" => item_id}, socket) do
item_id = String.to_integer(item_id)
item = Enum.find(socket.assigns.cart.items, &(&1.id == item_id))
if item do
case Shop.remove_from_cart(item) do
{:ok, updated_cart} ->
shipping_methods = Shop.get_available_shipping_methods(updated_cart)
{:noreply,
socket
|> assign(:cart, updated_cart)
|> assign(:shipping_methods, shipping_methods)
|> put_flash(:info, "Item removed from cart")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to remove item")}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("select_shipping", %{"method_id" => method_id}, socket) do
method_id = String.to_integer(method_id)
method = Enum.find(socket.assigns.shipping_methods, &(&1.id == method_id))
cart = socket.assigns.cart
if method do
# Country will be set at checkout based on billing info
case Shop.set_cart_shipping(cart, method, nil) do
{:ok, updated_cart} ->
{:noreply, assign(socket, :cart, updated_cart)}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to set shipping method")}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("proceed_to_checkout", _params, socket) do
cart = socket.assigns.cart
cond do
cart.items == [] ->
{:noreply, put_flash(socket, :error, "Your cart is empty")}
is_nil(cart.shipping_method_id) ->
{:noreply, put_flash(socket, :error, "Please select a shipping method")}
true ->
{:noreply, push_navigate(socket, to: Shop.checkout_url(socket.assigns.current_language))}
end
end
defp update_item_quantity(socket, item_id, quantity) do
item = Enum.find(socket.assigns.cart.items, &(&1.id == item_id))
if item do
case Shop.update_cart_item(item, quantity) do
{:ok, updated_cart} ->
shipping_methods = Shop.get_available_shipping_methods(updated_cart)
{:noreply,
socket
|> assign(:cart, updated_cart)
|> assign(:shipping_methods, shipping_methods)}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to update quantity")}
end
else
{:noreply, socket}
end
end
# ============================================
# PUBSUB EVENT HANDLERS
# ============================================
@impl true
def handle_info({:cart_updated, cart}, socket) do
shipping_methods = Shop.get_available_shipping_methods(cart)
{:noreply,
socket
|> assign(:cart, cart)
|> assign(:shipping_methods, shipping_methods)}
end
@impl true
def handle_info({:item_added, cart, _item}, socket) do
shipping_methods = Shop.get_available_shipping_methods(cart)
{:noreply,
socket
|> assign(:cart, cart)
|> assign(:shipping_methods, shipping_methods)}
end
@impl true
def handle_info({:item_removed, cart, _item_id}, socket) do
shipping_methods = Shop.get_available_shipping_methods(cart)
{:noreply,
socket
|> assign(:cart, cart)
|> assign(:shipping_methods, shipping_methods)}
end
@impl true
def handle_info({:quantity_updated, cart, _item}, socket) do
shipping_methods = Shop.get_available_shipping_methods(cart)
{:noreply,
socket
|> assign(:cart, cart)
|> assign(:shipping_methods, shipping_methods)}
end
@impl true
def handle_info({:shipping_selected, cart}, socket) do
{:noreply, assign(socket, :cart, cart)}
end
@impl true
def handle_info({:payment_selected, cart}, socket) do
{:noreply, assign(socket, :cart, cart)}
end
@impl true
def handle_info({:cart_cleared, cart}, socket) do
shipping_methods = Shop.get_available_shipping_methods(cart)
{:noreply,
socket
|> assign(:cart, cart)
|> assign(:shipping_methods, shipping_methods)}
end
@impl true
def render(assigns) do
~H"""
<.shop_layout {assigns}>
<div class="container flex-col mx-auto px-4 py-6 max-w-6xl">
<%!-- Header --%>
<header class="mb-6">
<div class="flex items-start gap-4">
<.link
navigate={Shop.catalog_url(@current_language)}
class="btn btn-outline btn-primary btn-sm shrink-0"
>
<.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> Continue Shopping
</.link>
<div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold text-base-content">Shopping Cart</h1>
<p class="text-base-content/70 mt-1">Review your items before checkout</p>
</div>
</div>
</header>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<%!-- Cart Items --%>
<div class="lg:col-span-2">
<%= if @cart.items == [] do %>
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center py-16">
<.icon name="hero-shopping-cart" class="w-16 h-16 mx-auto mb-4 opacity-30" />
<h2 class="text-xl font-medium text-base-content/60">Your cart is empty</h2>
<p class="text-base-content/50 mb-6">Add some products to get started</p>
<.link navigate={Shop.catalog_url(@current_language)} class="btn btn-primary">
Browse Products
</.link>
</div>
</div>
<% else %>
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-0">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th class="w-1/2">Product</th>
<th class="text-center">Quantity</th>
<th class="text-right">Price</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for item <- @cart.items do %>
<tr>
<td>
<div class="flex items-center gap-4">
<%= if item.product_image do %>
<div class="w-16 h-16 bg-base-200 rounded-lg overflow-hidden flex-shrink-0">
<img
src={item.product_image}
alt={item.product_title}
class="w-full h-full object-cover"
/>
</div>
<% else %>
<div class="w-16 h-16 bg-base-200 rounded-lg flex items-center justify-center flex-shrink-0">
<.icon name="hero-cube" class="w-8 h-8 opacity-30" />
</div>
<% end %>
<div>
<div class="font-medium">{item.product_title}</div>
<%= if item.product_sku do %>
<div class="text-xs text-base-content/50">
SKU: {item.product_sku}
</div>
<% end %>
<%= if item.selected_specs && item.selected_specs != %{} do %>
<div class="text-xs text-base-content/60 mt-1">
<%= for {key, value} <- item.selected_specs do %>
<span class="inline-block mr-2">
<span class="font-medium">{humanize_key(key)}:</span>
<span>{value}</span>
</span>
<% end %>
</div>
<% end %>
<%= if item.compare_at_price && Decimal.compare(item.compare_at_price, item.unit_price) == :gt do %>
<div class="text-xs">
<span class="line-through text-base-content/40">
{format_price(item.compare_at_price, @currency)}
</span>
<span class="text-success ml-1">On sale!</span>
</div>
<% end %>
</div>
</div>
</td>
<td class="text-center">
<form phx-change="update_quantity" class="inline">
<input type="hidden" name="item_id" value={item.id} />
<input
type="number"
name="quantity"
value={item.quantity}
min="1"
class="input input-bordered input-sm w-20 text-center"
/>
</form>
</td>
<td class="text-right">
<div class="font-semibold">
{format_price(item.line_total, @currency)}
</div>
<div class="text-xs text-base-content/50">
{format_price(item.unit_price, @currency)} each
</div>
</td>
<td>
<button
phx-click="remove_item"
phx-value-item_id={item.id}
class="btn btn-ghost btn-sm text-error"
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<% end %>
<%!-- Shipping Section --%>
<%= if @cart.items != [] do %>
<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title mb-4">Shipping Method</h2>
<%= if @shipping_methods == [] do %>
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span>No shipping methods available for your selection</span>
</div>
<% else %>
<div class="space-y-3">
<%= for method <- @shipping_methods do %>
<label class={[
"flex items-center gap-4 p-4 border rounded-lg cursor-pointer transition-colors",
if(@cart.shipping_method_id == method.id,
do: "border-primary bg-primary/5",
else: "border-base-300 hover:border-primary/50"
)
]}>
<input
type="radio"
name="shipping_method"
value={method.id}
checked={@cart.shipping_method_id == method.id}
phx-click="select_shipping"
phx-value-method_id={method.id}
class="radio radio-primary"
/>
<div class="flex-1">
<div class="font-medium">{method.name}</div>
<%= if method.description do %>
<div class="text-sm text-base-content/60">{method.description}</div>
<% end %>
<%= if estimate = ShippingMethod.delivery_estimate(method) do %>
<div class="text-sm text-base-content/50">{estimate}</div>
<% end %>
</div>
<div class="text-right">
<%= if ShippingMethod.free_for?(method, @cart.subtotal || Decimal.new("0")) do %>
<span class="badge badge-success">FREE</span>
<% else %>
<span class="font-semibold">
{format_price(method.price, @currency)}
</span>
<% end %>
</div>
</label>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<%!-- Order Summary --%>
<div class="lg:col-span-1">
<div class="card bg-base-100 shadow-xl sticky top-6">
<div class="card-body">
<h2 class="card-title mb-4">Order Summary</h2>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-base-content/70">
Subtotal ({@cart.items_count || 0} items)
</span>
<span>{format_price(@cart.subtotal, @currency)}</span>
</div>
<div class="flex justify-between">
<span class="text-base-content/70">Shipping</span>
<%= if is_nil(@cart.shipping_method_id) do %>
<span class="text-base-content/50">Select method</span>
<% else %>
<%= if Decimal.compare(@cart.shipping_amount || Decimal.new("0"), Decimal.new("0")) == :eq do %>
<span class="text-success">FREE</span>
<% else %>
<span>{format_price(@cart.shipping_amount, @currency)}</span>
<% end %>
<% end %>
</div>
<%= if @cart.discount_amount && Decimal.compare(@cart.discount_amount, Decimal.new("0")) == :gt do %>
<div class="flex justify-between text-success">
<span>Discount</span>
<span>-{format_price(@cart.discount_amount, @currency)}</span>
</div>
<% end %>
<%= if @cart.tax_amount && Decimal.compare(@cart.tax_amount, Decimal.new("0")) == :gt do %>
<div class="flex justify-between">
<span class="text-base-content/70">Tax</span>
<span>{format_price(@cart.tax_amount, @currency)}</span>
</div>
<% end %>
<div class="divider my-2"></div>
<div class="flex justify-between text-lg font-bold">
<span>Total</span>
<span>{format_price(@cart.total, @currency)}</span>
</div>
</div>
<button
phx-click="proceed_to_checkout"
class="btn btn-primary btn-block mt-6"
disabled={@cart.items == [] || is_nil(@cart.shipping_method_id)}
>
<.icon name="hero-credit-card" class="w-5 h-5 mr-2" /> Proceed to Checkout
</button>
<%= if @cart.items != [] do %>
<p class="text-xs text-center text-base-content/50 mt-3">
Secure checkout powered by PhoenixKit
</p>
<% end %>
</div>
</div>
</div>
</div>
</div>
</.shop_layout>
"""
end
# Layout wrapper - uses dashboard for authenticated, app_layout for guests
slot :inner_block, required: true
defp shop_layout(assigns) do
~H"""
<%= if @authenticated do %>
<PhoenixKitWeb.Layouts.dashboard {assigns}>
{render_slot(@inner_block)}
</PhoenixKitWeb.Layouts.dashboard>
<% else %>
<PhoenixKitWeb.Components.LayoutWrapper.app_layout
flash={@flash}
phoenix_kit_current_scope={@phoenix_kit_current_scope}
current_path={@url_path}
current_locale={@current_locale}
page_title={@page_title}
>
{render_slot(@inner_block)}
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>
<% end %>
"""
end
# Private helpers
defp generate_session_id do
:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
end
defp format_price(nil, _currency), do: "-"
defp format_price(amount, %Currency{} = currency) do
Currency.format_amount(amount, currency)
end
defp format_price(amount, nil) do
"$#{Decimal.round(amount, 2)}"
end
defp get_current_user(socket) do
case socket.assigns[:phoenix_kit_current_scope] do
%{user: %{id: _} = user} -> user
_ -> nil
end
end
# Convert key to human-readable format: "material_type" -> "Material Type"
defp humanize_key(key) when is_binary(key) do
key
|> String.replace("_", " ")
|> String.split(" ")
|> Enum.map_join(" ", &String.capitalize/1)
end
defp humanize_key(key), do: to_string(key)
end