defmodule PhoenixKit.Modules.Shop.Web.CheckoutPage do
@moduledoc """
Checkout page LiveView for converting cart to order.
Supports both logged-in users (with billing profiles) and guest checkout.
Supports real-time cart synchronization across multiple browser tabs
via PubSub subscription.
"""
use PhoenixKitWeb, :live_view
alias PhoenixKit.Modules.Billing
alias PhoenixKit.Modules.Billing.CountryData
alias PhoenixKit.Modules.Billing.PaymentOption
alias PhoenixKit.Modules.Shop
alias PhoenixKit.Modules.Shop.Events
alias PhoenixKit.Modules.Shop.Web.Components.ShopLayouts
import PhoenixKit.Modules.Shop.Web.Helpers,
only: [
format_price: 2,
humanize_key: 1,
profile_display_name: 1,
profile_address: 1,
get_current_user: 1
]
alias PhoenixKit.Utils.Routes
@impl true
def mount(_params, session, socket) do
user = get_current_user(socket)
session_id = session["shop_session_id"]
user_uuid = if user, do: user.uuid
case Shop.find_active_cart(user_uuid: user_uuid, session_id: session_id) do
nil ->
{:ok, redirect_to_cart(socket, "Your cart is empty")}
cart ->
handle_cart_validation(socket, cart, user)
end
end
defp handle_cart_validation(socket, cart, user) do
cond do
Enum.empty?(cart.items) ->
{:ok, redirect_to_cart(socket, "Your cart is empty")}
is_nil(cart.shipping_method_uuid) ->
{:ok, redirect_to_cart(socket, "Please select a shipping method")}
true ->
{:ok, setup_checkout_assigns(socket, cart, user)}
end
end
defp setup_checkout_assigns(socket, cart, user) do
is_guest = is_nil(user)
authenticated = not is_nil(socket.assigns[:phoenix_kit_current_user])
# Subscribe to cart events for real-time sync across tabs
if connected?(socket) do
Events.subscribe_to_cart(cart)
end
# Load and auto-select payment option
payment_options = Billing.list_active_payment_options()
{cart, selected_payment_option, needs_payment_selection} =
prepare_payment_options(cart, payment_options)
# Load billing profiles
billing_profiles = load_billing_profiles(user)
{selected_profile, needs_profile_selection} = select_billing_profile(billing_profiles)
# Determine if billing is needed and initial step
needs_billing =
payment_option_needs_billing?(selected_payment_option, is_guest, billing_profiles)
initial_step =
determine_initial_step(
needs_payment_selection,
needs_billing,
is_guest,
billing_profiles,
needs_profile_selection
)
build_checkout_socket(socket, %{
cart: cart,
is_guest: is_guest,
authenticated: authenticated,
payment_options: payment_options,
selected_payment_option: selected_payment_option,
needs_payment_selection: needs_payment_selection,
billing_profiles: billing_profiles,
selected_profile: selected_profile,
needs_profile_selection: needs_profile_selection,
needs_billing: needs_billing,
initial_step: initial_step,
user: user
})
end
defp prepare_payment_options(cart, payment_options) do
{selected, needs_selection} = select_payment_option(payment_options, cart)
cart = maybe_auto_select_payment(cart, payment_options)
{cart, selected, needs_selection}
end
defp maybe_auto_select_payment(cart, payment_options) do
if length(payment_options) == 1 and is_nil(cart.payment_option_uuid) do
case Shop.set_cart_payment_option(cart, hd(payment_options)) do
{:ok, updated_cart} -> updated_cart
_ -> cart
end
else
cart
end
end
defp determine_initial_step(needs_payment, needs_billing, is_guest, profiles, needs_profile) do
cond do
needs_payment -> :payment
needs_billing and (is_guest or profiles == []) -> :billing
needs_billing and needs_profile -> :billing
true -> :review
end
end
defp build_checkout_socket(socket, assigns) do
socket
|> assign(:page_title, "Checkout")
|> assign(:cart, assigns.cart)
|> assign(:currency, Shop.get_default_currency())
|> assign(:is_guest, assigns.is_guest)
|> assign(:authenticated, assigns.authenticated)
|> assign(:payment_options, assigns.payment_options)
|> assign(:selected_payment_option, assigns.selected_payment_option)
|> assign(:needs_payment_selection, assigns.needs_payment_selection)
|> assign(:billing_profiles, assigns.billing_profiles)
|> assign(
:selected_profile_uuid,
if(assigns.selected_profile, do: assigns.selected_profile.uuid)
)
|> assign(:use_new_profile, assigns.is_guest or assigns.billing_profiles == [])
|> assign(:needs_profile_selection, assigns.needs_profile_selection)
|> assign(:needs_billing, assigns.needs_billing)
|> assign(:billing_data, initial_billing_data(assigns.user, assigns.cart))
|> assign(:countries, CountryData.list_countries())
|> assign(:step, assigns.initial_step)
|> assign(:processing, false)
|> assign(:error_message, nil)
|> assign(:email_exists_error, false)
|> assign(:form_errors, %{})
end
# Select payment option with smart defaults
defp select_payment_option([], _cart), do: {nil, false}
defp select_payment_option(options, cart) do
# Check if cart already has a payment option selected
selected =
if cart.payment_option_uuid do
Enum.find(options, &(&1.uuid == cart.payment_option_uuid))
end
cond do
# Cart has valid selected option
selected -> {selected, false}
# Only one option available
length(options) == 1 -> {hd(options), false}
# Multiple options - user must choose
true -> {hd(options), true}
end
end
# Check if billing info is needed for the payment option
defp payment_option_needs_billing?(nil, _is_guest, _profiles), do: true
defp payment_option_needs_billing?(
%PaymentOption{requires_billing_profile: true},
_is_guest,
_profiles
),
do: true
defp payment_option_needs_billing?(
%PaymentOption{requires_billing_profile: false},
true,
_profiles
),
do: true
defp payment_option_needs_billing?(
%PaymentOption{requires_billing_profile: false},
false,
_profiles
),
do: false
# Select billing profile with smart defaults
defp select_billing_profile([]), do: {nil, false}
defp select_billing_profile(profiles) do
default = Enum.find(profiles, & &1.is_default)
cond do
# Has default profile - use it
default -> {default, false}
# Only one profile - auto-select it
length(profiles) == 1 -> {hd(profiles), false}
# Multiple profiles without default - select first, show prompt
true -> {hd(profiles), true}
end
end
defp load_billing_profiles(nil), do: []
defp load_billing_profiles(user), do: Billing.list_user_billing_profiles(user.uuid)
defp initial_billing_data(user, cart) do
%{
"type" => "individual",
"first_name" => "",
"last_name" => "",
"email" => if(user, do: user.email, else: ""),
"phone" => "",
"address_line1" => "",
"city" => "",
"postal_code" => "",
"country" => cart.shipping_country || "EE"
}
end
defp profile_to_billing_data(profile, cart) do
%{
"type" => profile.type || "individual",
"first_name" => profile.first_name || "",
"last_name" => profile.last_name || "",
"email" => profile.email || "",
"phone" => profile.phone || "",
"address_line1" => profile.address_line1 || "",
"city" => profile.city || "",
"postal_code" => profile.postal_code || "",
"country" => profile.country || cart.shipping_country || "EE"
}
end
defp redirect_to_cart(socket, message) do
socket
|> put_flash(:error, message)
|> push_navigate(to: Routes.path("/cart"))
end
@impl true
def handle_event("select_payment_option", %{"option_uuid" => option_uuid}, socket) do
option = Enum.find(socket.assigns.payment_options, &(&1.uuid == option_uuid))
if option do
case Shop.set_cart_payment_option(socket.assigns.cart, option) do
{:ok, updated_cart} ->
# Update needs_billing based on new payment option
needs_billing =
payment_option_needs_billing?(
option,
socket.assigns.is_guest,
socket.assigns.billing_profiles
)
{:noreply,
socket
|> assign(:cart, updated_cart)
|> assign(:selected_payment_option, option)
|> assign(:needs_billing, needs_billing)}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to set payment option")}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("proceed_to_billing", _params, socket) do
if socket.assigns.needs_billing do
{:noreply, assign(socket, :step, :billing)}
else
{:noreply, assign(socket, :step, :review)}
end
end
@impl true
def handle_event("back_to_payment", _params, socket) do
{:noreply, assign(socket, :step, :payment)}
end
@impl true
def handle_event("select_profile", %{"profile_uuid" => profile_uuid}, socket) do
{:noreply,
socket
|> assign(:selected_profile_uuid, profile_uuid)
|> assign(:use_new_profile, false)}
end
@impl true
def handle_event("use_new_profile", _params, socket) do
# Pre-fill form from selected profile if available
billing_data =
case Enum.find(
socket.assigns.billing_profiles,
&(to_string(&1.uuid) == to_string(socket.assigns.selected_profile_uuid))
) do
nil -> socket.assigns.billing_data
profile -> profile_to_billing_data(profile, socket.assigns.cart)
end
{:noreply,
socket
|> assign(:use_new_profile, true)
|> assign(:billing_data, billing_data)
|> assign(:selected_profile_uuid, nil)}
end
@impl true
def handle_event("use_existing_profile", _params, socket) do
default_profile = Enum.find(socket.assigns.billing_profiles, & &1.is_default)
first_profile = List.first(socket.assigns.billing_profiles)
profile = default_profile || first_profile
{:noreply,
socket
|> assign(:use_new_profile, false)
|> assign(:selected_profile_uuid, if(profile, do: profile.uuid))}
end
@impl true
def handle_event("update_billing", %{"billing" => params}, socket) do
billing_data = Map.merge(socket.assigns.billing_data, params)
{:noreply, assign(socket, :billing_data, billing_data)}
end
@impl true
def handle_event("proceed_to_review", _params, socket) do
if socket.assigns.use_new_profile do
# Validate billing data
errors = validate_billing_data(socket.assigns.billing_data)
if Enum.empty?(errors) do
{:noreply, assign(socket, step: :review, form_errors: %{})}
else
{:noreply,
socket
|> assign(:form_errors, errors)
|> put_flash(:error, "Please fill in all required fields")}
end
else
if is_nil(socket.assigns.selected_profile_uuid) do
{:noreply, put_flash(socket, :error, "Please select a billing profile")}
else
{:noreply, assign(socket, :step, :review)}
end
end
end
@impl true
def handle_event("back_to_billing", _params, socket) do
{:noreply, assign(socket, :step, :billing)}
end
@impl true
def handle_event("confirm_order", _params, socket) do
socket = assign(socket, :processing, true)
cart = socket.assigns.cart
# Get user identifier from current scope if logged in
user_uuid =
case socket.assigns[:phoenix_kit_current_scope] do
%{user: %{uuid: uuid}} -> uuid
_ -> nil
end
# Build options for convert_cart_to_order
opts =
if socket.assigns.use_new_profile do
# Guest or new profile - use billing_data directly
[billing_data: socket.assigns.billing_data, user_uuid: user_uuid]
else
# Logged-in user with existing profile
[billing_profile_uuid: socket.assigns.selected_profile_uuid, user_uuid: user_uuid]
end
case Shop.convert_cart_to_order(cart, opts) do
{:ok, order} ->
{:noreply,
socket
|> assign(:processing, false)
|> push_navigate(to: Routes.path("/checkout/complete/#{order.uuid}"))}
{:error, :cart_not_active} ->
{:noreply,
socket
|> assign(:processing, false)
|> assign(:error_message, "Cart is no longer active")
|> put_flash(:error, "Cart is no longer active")}
{:error, :cart_empty} ->
{:noreply,
socket
|> assign(:processing, false)
|> push_navigate(to: Routes.path("/cart"))}
{:error, :no_shipping_method} ->
{:noreply,
socket
|> assign(:processing, false)
|> put_flash(:error, "Please select a shipping method")
|> push_navigate(to: Routes.path("/cart"))}
{:error, :email_already_registered} ->
{:noreply,
socket
|> assign(:processing, false)
|> assign(:email_exists_error, true)
|> assign(:error_message, nil)}
{:error, _reason} ->
{:noreply,
socket
|> assign(:processing, false)
|> assign(:error_message, "Failed to create order. Please try again.")
|> put_flash(:error, "Failed to create order")}
end
end
defp validate_billing_data(data) do
errors = %{}
errors =
if blank?(data["first_name"]),
do: Map.put(errors, :first_name, "is required"),
else: errors
errors =
if blank?(data["last_name"]),
do: Map.put(errors, :last_name, "is required"),
else: errors
errors =
if blank?(data["email"]),
do: Map.put(errors, :email, "is required"),
else: errors
errors =
if blank?(data["address_line1"]),
do: Map.put(errors, :address_line1, "is required"),
else: errors
errors =
if blank?(data["city"]), do: Map.put(errors, :city, "is required"), else: errors
errors =
if blank?(data["country"]),
do: Map.put(errors, :country, "is required"),
else: errors
errors
end
defp blank?(nil), do: true
defp blank?(""), do: true
defp blank?(str) when is_binary(str), do: String.trim(str) == ""
defp blank?(_), do: false
# ============================================
# PUBSUB EVENT HANDLERS
# ============================================
@impl true
def handle_info({:cart_updated, cart}, socket) do
{:noreply, assign(socket, :cart, cart)}
end
@impl true
def handle_info({:item_added, cart, _item}, socket) do
{:noreply, assign(socket, :cart, cart)}
end
@impl true
def handle_info({:item_removed, cart, _item_id}, socket) do
# If cart becomes empty, redirect to cart page
if Enum.empty?(cart.items) do
{:noreply, redirect_to_cart(socket, "Your cart is empty")}
else
{:noreply, assign(socket, :cart, cart)}
end
end
@impl true
def handle_info({:quantity_updated, cart, _item}, socket) do
{:noreply, assign(socket, :cart, cart)}
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
# Also update selected_payment_option if it changed
selected = Enum.find(socket.assigns.payment_options, &(&1.uuid == cart.payment_option_uuid))
{:noreply,
socket
|> assign(:cart, cart)
|> assign(:selected_payment_option, selected)}
end
@impl true
def handle_info({:cart_cleared, _cart}, socket) do
# Cart was cleared, redirect to cart page
{:noreply, redirect_to_cart(socket, "Your cart is empty")}
end
@impl true
def render(assigns) do
~H"""
Checkout
<.link navigate={Routes.path("/cart")} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left" class="w-4 h-4" />
An account with this email is already registered. Please log in to complete your order.