Packages
phoenix_kit
1.7.49
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/checkout_page.ex
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.BillingProfile
alias PhoenixKit.Modules.Billing.CountryData
alias PhoenixKit.Modules.Billing.Currency
alias PhoenixKit.Modules.Billing.PaymentOption
alias PhoenixKit.Modules.Shop
alias PhoenixKit.Modules.Shop.Events
alias PhoenixKit.Utils.Routes
@impl true
def mount(_params, session, socket) do
user = get_current_user(socket)
session_id = session["shop_session_id"]
user_id = if user, do: user.id
case Shop.find_active_cart(user_id: user_id, 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_id) ->
{: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_id, if(assigns.selected_profile, do: assigns.selected_profile.id))
|> 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.id)
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_id" => profile_id}, socket) do
{:noreply,
socket
|> assign(:selected_profile_id, profile_id)
|> 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.id) == to_string(socket.assigns.selected_profile_id))
) 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_id, 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_id, if(profile, do: profile.id))}
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_id) 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_id from current scope if logged in
user_id =
case socket.assigns[:phoenix_kit_current_scope] do
%{user: %{id: id}} -> id
_ -> 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_id: user_id]
else
# Logged-in user with existing profile
[billing_profile_id: socket.assigns.selected_profile_id, user_id: user_id]
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"""
<.shop_layout {assigns}>
<div class="p-6 max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold">Checkout</h1>
<.link navigate={Routes.path("/cart")} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left" class="w-4 h-4" />
</.link>
</div>
<%!-- Steps Indicator --%>
<div class="steps w-full mb-8">
<%= if length(@payment_options) > 1 do %>
<div class={["step", @step in [:payment, :billing, :review] && "step-primary"]}>
Payment
</div>
<% end %>
<%= if @needs_billing do %>
<div class={["step", @step in [:billing, :review] && "step-primary"]}>Billing</div>
<% end %>
<div class={["step", @step == :review && "step-primary"]}>Review & Confirm</div>
</div>
<%!-- Guest Checkout Info --%>
<%= if @is_guest do %>
<div class="alert alert-info mb-6">
<.icon name="hero-envelope" class="w-5 h-5" />
<div>
<div class="font-semibold">Checking out as a guest</div>
<div class="text-sm">
After placing your order, we'll send a confirmation email to verify your address.
Check your inbox and click the link to activate your account and track your order.
</div>
</div>
</div>
<% end %>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<%!-- Main Content --%>
<div class="lg:col-span-2">
<%= case @step do %>
<% :payment -> %>
<.payment_step
payment_options={@payment_options}
selected_payment_option={@selected_payment_option}
needs_billing={@needs_billing}
/>
<% :billing -> %>
<.billing_step
is_guest={@is_guest}
billing_profiles={@billing_profiles}
selected_profile_id={@selected_profile_id}
use_new_profile={@use_new_profile}
needs_profile_selection={@needs_profile_selection}
billing_data={@billing_data}
form_errors={@form_errors}
countries={@countries}
payment_options={@payment_options}
/>
<% :review -> %>
<.review_step
cart={@cart}
is_guest={@is_guest}
billing_profiles={@billing_profiles}
selected_profile_id={@selected_profile_id}
use_new_profile={@use_new_profile}
billing_data={@billing_data}
currency={@currency}
processing={@processing}
error_message={@error_message}
email_exists_error={@email_exists_error}
selected_payment_option={@selected_payment_option}
needs_billing={@needs_billing}
payment_options={@payment_options}
/>
<% end %>
</div>
<%!-- Order Summary Sidebar --%>
<div class="lg:col-span-1">
<.order_summary cart={@cart} currency={@currency} />
</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 {dashboard_assigns(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
# Components
defp payment_step(assigns) do
~H"""
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title mb-4">Select Payment Method</h2>
<div class="space-y-3">
<%= for option <- @payment_options do %>
<label class={[
"flex items-center gap-4 p-4 border rounded-lg cursor-pointer transition-colors",
if(@selected_payment_option && @selected_payment_option.uuid == option.uuid,
do: "border-primary bg-primary/5",
else: "border-base-300 hover:border-primary/50"
)
]}>
<input
type="radio"
name="payment_option"
value={option.uuid}
checked={@selected_payment_option && @selected_payment_option.uuid == option.uuid}
phx-click="select_payment_option"
phx-value-option_uuid={option.uuid}
class="radio radio-primary"
/>
<.icon name={PaymentOption.icon_name(option)} class="w-6 h-6 text-base-content/70" />
<div class="flex-1">
<div class="font-medium">{option.name}</div>
<%= if option.description do %>
<div class="text-sm text-base-content/60">{option.description}</div>
<% end %>
</div>
</label>
<% end %>
</div>
<div class="card-actions justify-end mt-6">
<button phx-click="proceed_to_billing" class="btn btn-primary">
<%= if @needs_billing do %>
Continue to Billing <.icon name="hero-arrow-right" class="w-4 h-4 ml-2" />
<% else %>
Continue to Review <.icon name="hero-arrow-right" class="w-4 h-4 ml-2" />
<% end %>
</button>
</div>
</div>
</div>
"""
end
defp billing_step(assigns) do
~H"""
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title mb-4">
<%= if @is_guest or @billing_profiles == [] do %>
Billing Information
<% else %>
Select Billing Profile
<% end %>
</h2>
<%= if @use_new_profile do %>
<%!-- Guest checkout or no profiles - show billing form --%>
<.billing_form
billing_data={@billing_data}
form_errors={@form_errors}
countries={@countries}
/>
<% else %>
<%!-- Authenticated user with multiple profiles - show selector --%>
<.profile_selector
billing_profiles={@billing_profiles}
selected_profile_id={@selected_profile_id}
needs_profile_selection={@needs_profile_selection}
/>
<% end %>
<div class="card-actions justify-between mt-6">
<%= if length(@payment_options) > 1 do %>
<button phx-click="back_to_payment" class="btn btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" />
</button>
<% else %>
<div></div>
<% end %>
<button phx-click="proceed_to_review" class="btn btn-primary">
Continue to Review <.icon name="hero-arrow-right" class="w-4 h-4 ml-2" />
</button>
</div>
</div>
</div>
"""
end
defp profile_selector(assigns) do
~H"""
<div class="space-y-3">
<%!-- Show info alert when multiple profiles exist without a default --%>
<%= if @needs_profile_selection do %>
<div class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="w-5 h-5" />
<span>
You have multiple billing profiles. Please select one or <.link
navigate={Routes.path("/dashboard/billing-profiles")}
class="link"
>
set a default in your account settings
</.link>.
</span>
</div>
<% end %>
<%= for profile <- @billing_profiles do %>
<div class={[
"flex items-start gap-4 p-4 border rounded-lg transition-colors",
if(to_string(@selected_profile_id) == to_string(profile.id),
do: "border-primary bg-primary/5",
else: "border-base-300 hover:border-primary/50"
)
]}>
<label class="flex items-start gap-4 flex-1 cursor-pointer">
<input
type="radio"
name="profile"
value={profile.id}
checked={to_string(@selected_profile_id) == to_string(profile.id)}
phx-click="select_profile"
phx-value-profile_id={profile.id}
class="radio radio-primary mt-1"
/>
<div class="flex-1">
<div class="font-medium flex items-center gap-2">
{profile_display_name(profile)}
<%= if profile.is_default do %>
<span class="badge badge-primary badge-sm">Default</span>
<% end %>
</div>
<div class="text-sm text-base-content/60 mt-1">
{profile_address(profile)}
</div>
<%= if profile.email do %>
<div class="text-sm text-base-content/60">
{profile.email}
</div>
<% end %>
</div>
</label>
<%!-- Edit button for selected profile --%>
<%= if to_string(@selected_profile_id) == to_string(profile.id) do %>
<.link
navigate={
Routes.path("/dashboard/billing-profiles/#{profile.uuid}/edit?return_to=/checkout")
}
class="btn btn-ghost btn-sm"
>
<.icon name="hero-pencil" class="w-4 h-4" />
</.link>
<% end %>
</div>
<% end %>
</div>
"""
end
defp billing_form(assigns) do
~H"""
<form phx-change="update_billing" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">First Name *</span></label>
<input
type="text"
name="billing[first_name]"
value={@billing_data["first_name"]}
class={["input input-bordered", @form_errors[:first_name] && "input-error"]}
required
/>
<%= if @form_errors[:first_name] do %>
<label class="label">
<span class="label-text-alt text-error">{@form_errors[:first_name]}</span>
</label>
<% end %>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Last Name *</span></label>
<input
type="text"
name="billing[last_name]"
value={@billing_data["last_name"]}
class={["input input-bordered", @form_errors[:last_name] && "input-error"]}
required
/>
<%= if @form_errors[:last_name] do %>
<label class="label">
<span class="label-text-alt text-error">{@form_errors[:last_name]}</span>
</label>
<% end %>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Email *</span></label>
<input
type="email"
name="billing[email]"
value={@billing_data["email"]}
class={["input input-bordered", @form_errors[:email] && "input-error"]}
required
/>
<%= if @form_errors[:email] do %>
<label class="label">
<span class="label-text-alt text-error">{@form_errors[:email]}</span>
</label>
<% end %>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Phone</span></label>
<input
type="tel"
name="billing[phone]"
value={@billing_data["phone"]}
class="input input-bordered"
/>
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Address *</span></label>
<input
type="text"
name="billing[address_line1]"
value={@billing_data["address_line1"]}
class={["input input-bordered", @form_errors[:address_line1] && "input-error"]}
placeholder="Street address"
required
/>
<%= if @form_errors[:address_line1] do %>
<label class="label">
<span class="label-text-alt text-error">{@form_errors[:address_line1]}</span>
</label>
<% end %>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">City *</span></label>
<input
type="text"
name="billing[city]"
value={@billing_data["city"]}
class={["input input-bordered", @form_errors[:city] && "input-error"]}
required
/>
<%= if @form_errors[:city] do %>
<label class="label">
<span class="label-text-alt text-error">{@form_errors[:city]}</span>
</label>
<% end %>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Postal Code</span></label>
<input
type="text"
name="billing[postal_code]"
value={@billing_data["postal_code"]}
class="input input-bordered"
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Country *</span></label>
<select
name="billing[country]"
class={["select select-bordered", @form_errors[:country] && "select-error"]}
required
>
<option value="">Select country...</option>
<%= for country <- @countries do %>
<option value={country.alpha2} selected={@billing_data["country"] == country.alpha2}>
{country.name}
</option>
<% end %>
</select>
<%= if @form_errors[:country] do %>
<label class="label">
<span class="label-text-alt text-error">{@form_errors[:country]}</span>
</label>
<% end %>
</div>
</div>
</form>
"""
end
defp review_step(assigns) do
selected_profile =
if assigns.use_new_profile do
nil
else
Enum.find(
assigns.billing_profiles,
&(to_string(&1.id) == to_string(assigns.selected_profile_id))
)
end
assigns = assign(assigns, :selected_profile, selected_profile)
~H"""
<div class="space-y-6">
<%!-- Payment Method --%>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Payment Method</h2>
<%= if length(@payment_options) > 1 do %>
<button phx-click="back_to_payment" class="btn btn-ghost btn-sm">
<.icon name="hero-pencil" class="w-4 h-4 mr-1" /> Change
</button>
<% end %>
</div>
<%= if @selected_payment_option do %>
<div class="flex items-center gap-3">
<.icon
name={PaymentOption.icon_name(@selected_payment_option)}
class="w-6 h-6 text-base-content/70"
/>
<div>
<div class="font-medium">{@selected_payment_option.name}</div>
<%= if @selected_payment_option.description do %>
<div class="text-sm text-base-content/60">
{@selected_payment_option.description}
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Billing Info (only if billing is needed) --%>
<%= if @needs_billing do %>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Billing Information</h2>
<button phx-click="back_to_billing" class="btn btn-ghost btn-sm">
<.icon name="hero-pencil" class="w-4 h-4 mr-1" /> Change
</button>
</div>
<div class="text-sm">
<%= if @use_new_profile do %>
<div class="font-medium">
{@billing_data["first_name"]} {@billing_data["last_name"]}
</div>
<div class="text-base-content/60">
{[
@billing_data["address_line1"],
@billing_data["city"],
@billing_data["postal_code"],
@billing_data["country"]
]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(", ")}
</div>
<div class="text-base-content/60">{@billing_data["email"]}</div>
<%= if @billing_data["phone"] && @billing_data["phone"] != "" do %>
<div class="text-base-content/60">{@billing_data["phone"]}</div>
<% end %>
<% else %>
<%= if @selected_profile do %>
<div class="font-medium">{profile_display_name(@selected_profile)}</div>
<div class="text-base-content/60">{profile_address(@selected_profile)}</div>
<%= if @selected_profile.email do %>
<div class="text-base-content/60">{@selected_profile.email}</div>
<% end %>
<%= if @selected_profile.phone do %>
<div class="text-base-content/60">{@selected_profile.phone}</div>
<% end %>
<% end %>
<% end %>
</div>
</div>
</div>
<% end %>
<%!-- Shipping Info --%>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Shipping Method</h2>
<.link navigate={Routes.path("/cart")} class="btn btn-ghost btn-sm">
<.icon name="hero-pencil" class="w-4 h-4 mr-1" /> Change
</.link>
</div>
<%= if @cart.shipping_method do %>
<div class="flex justify-between items-center">
<div>
<div class="font-medium">{@cart.shipping_method.name}</div>
<%= if @cart.shipping_method.description do %>
<div class="text-sm text-base-content/60">{@cart.shipping_method.description}</div>
<% end %>
</div>
<div class="font-semibold">
<%= if Decimal.compare(@cart.shipping_amount || Decimal.new("0"), Decimal.new("0")) == :eq do %>
<span class="text-success">FREE</span>
<% else %>
{format_price(@cart.shipping_amount, @currency)}
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Order Items --%>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Order Items</h2>
<.link navigate={Routes.path("/cart")} class="btn btn-ghost btn-sm">
<.icon name="hero-pencil" class="w-4 h-4 mr-1" /> Edit Cart
</.link>
</div>
<div class="space-y-4">
<%= for item <- @cart.items do %>
<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 class="flex-1">
<div class="font-medium">{item.product_title}</div>
<%= if item.selected_specs && item.selected_specs != %{} do %>
<div class="text-xs text-base-content/60 mt-0.5">
<%= 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 %>
<div class="text-sm text-base-content/60">
Qty: {item.quantity} × {format_price(item.unit_price, @currency)}
</div>
</div>
<div class="font-semibold">
{format_price(item.line_total, @currency)}
</div>
</div>
<% end %>
</div>
</div>
</div>
<%!-- Email Already Registered --%>
<%= if @email_exists_error do %>
<div class="card bg-warning/10 border border-warning">
<div class="card-body">
<div class="flex items-start gap-4">
<.icon name="hero-user-circle" class="w-8 h-8 text-warning flex-shrink-0" />
<div>
<h3 class="font-semibold text-lg">Account already exists</h3>
<p class="text-sm mt-1">
An account with this email is already registered.
Please log in to complete your order.
</p>
<div class="mt-3">
<.link
navigate={Routes.path("/users/log-in") <> "?return_to=" <> Routes.path("/checkout")}
class="btn btn-primary btn-sm"
>
<.icon name="hero-arrow-right-on-rectangle" class="w-4 h-4 mr-1" />
Log in to continue
</.link>
</div>
</div>
</div>
</div>
</div>
<% end %>
<%!-- Error Message --%>
<%= if @error_message do %>
<div class="alert alert-error">
<.icon name="hero-exclamation-circle" class="w-5 h-5" />
<span>{@error_message}</span>
</div>
<% end %>
<%!-- Confirm Button --%>
<div class="flex justify-between items-center">
<%= cond do %>
<% @needs_billing -> %>
<button phx-click="back_to_billing" class="btn btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" />
</button>
<% length(@payment_options) > 1 -> %>
<button phx-click="back_to_payment" class="btn btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" />
</button>
<% true -> %>
<div></div>
<% end %>
<button
phx-click="confirm_order"
class={["btn btn-primary btn-lg", @processing && "loading"]}
disabled={@processing}
>
<%= if @processing do %>
Processing...
<% else %>
<.icon name="hero-check" class="w-5 h-5 mr-2" /> Confirm Order
<% end %>
</button>
</div>
</div>
"""
end
defp order_summary(assigns) do
~H"""
<div class="card bg-base-100 shadow-lg 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">-</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.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 %>
<%= 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 %>
<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>
</div>
</div>
"""
end
# Helpers
defp profile_display_name(%BillingProfile{type: "company"} = profile) do
profile.company_name || "#{profile.first_name} #{profile.last_name}"
end
defp profile_display_name(%BillingProfile{} = profile) do
"#{profile.first_name} #{profile.last_name}"
end
defp profile_address(%BillingProfile{} = profile) do
[profile.address_line1, profile.city, profile.postal_code, profile.country]
|> Enum.filter(& &1)
|> Enum.join(", ")
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