Packages

phoenix_kit

1.7.65
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
phoenix_kit lib modules shop web cart_page.ex
Raw

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.Languages.DialectMapper
alias PhoenixKit.Modules.Shop
alias PhoenixKit.Modules.Shop.Events
alias PhoenixKit.Modules.Shop.ShippingMethod
alias PhoenixKit.Modules.Shop.Translations
alias PhoenixKit.Modules.Shop.Web.Components.ShopLayouts
import PhoenixKit.Modules.Shop.Web.Helpers,
only: [format_price: 2, humanize_key: 1, get_current_user: 1]
@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_uuid = if user, do: user.uuid, else: nil
# Get or create cart
{:ok, cart} =
Shop.get_or_create_cart(user_uuid: user_uuid, 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_uuid" => item_uuid, "quantity" => quantity}, socket) do
quantity = max(1, String.to_integer(quantity))
update_item_quantity(socket, item_uuid, quantity)
end
@impl true
def handle_event("remove_item", %{"item_uuid" => item_uuid}, socket) do
item = Enum.find(socket.assigns.cart.items, &(&1.uuid == item_uuid))
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_uuid" => method_uuid}, socket) do
method = Enum.find(socket.assigns.shipping_methods, &(&1.uuid == method_uuid))
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_uuid) ->
{: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_uuid, quantity) do
item = Enum.find(socket.assigns.cart.items, &(&1.uuid == item_uuid))
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"""
<ShopLayouts.shop_layout {assigns}>
<div class="container 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-ghost btn-sm"
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
</.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 %>
<%= if item.product_slug do %>
<.link
navigate={product_item_url(item, @current_language)}
class="w-16 h-16 bg-base-200 rounded-lg overflow-hidden flex-shrink-0 block"
>
<img
src={item.product_image}
alt={item.product_title}
class="w-full h-full object-cover"
/>
</.link>
<% else %>
<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>
<% end %>
<% else %>
<%= if item.product_slug do %>
<.link
navigate={product_item_url(item, @current_language)}
class="w-16 h-16 bg-base-200 rounded-lg flex items-center justify-center flex-shrink-0 block"
>
<.icon name="hero-cube" class="w-8 h-8 opacity-30" />
</.link>
<% 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 %>
<% end %>
<div>
<div class="font-medium">
<%= if item.product_slug do %>
<.link
navigate={product_item_url(item, @current_language)}
class="hover:text-primary transition-colors"
>
{item.product_title}
</.link>
<% else %>
{item.product_title}
<% end %>
</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_uuid" value={item.uuid} />
<input
type="number"
name="quantity"
value={item.quantity}
min="1"
class="input 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_uuid={item.uuid}
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_uuid == method.uuid,
do: "border-primary bg-primary/5",
else: "border-base-300 hover:border-primary/50"
)
]}>
<input
type="radio"
name="shipping_method"
value={method.uuid}
checked={@cart.shipping_method_uuid == method.uuid}
phx-click="select_shipping"
phx-value-method_uuid={method.uuid}
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_uuid) 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_uuid)}
>
<.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>
</ShopLayouts.shop_layout>
"""
end
# Private helpers
defp generate_session_id do
:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
end
defp product_item_url(item, language) do
base = DialectMapper.extract_base(language)
Routes.path("/shop/product/#{item.product_slug}", locale: base)
end
end