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" />
<%!-- Steps Indicator --%>
<%= if length(@payment_options) > 1 do %>
Payment
<% end %> <%= if @needs_billing do %>
Billing
<% end %>
Review & Confirm
<%!-- Guest Checkout Info --%> <%= if @is_guest do %>
<.icon name="hero-envelope" class="w-5 h-5" />
Checking out as a guest
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.
<% end %>
<%!-- Main Content --%>
<%= 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_uuid={@selected_profile_uuid} 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_uuid={@selected_profile_uuid} 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 %>
<%!-- Order Summary Sidebar --%>
<.order_summary cart={@cart} currency={@currency} />
""" end # Components defp payment_step(assigns) do ~H"""

Select Payment Method

<%= for option <- @payment_options do %> <% end %>
""" end defp billing_step(assigns) do ~H"""

<%= if @is_guest or @billing_profiles == [] do %> Billing Information <% else %> Select Billing Profile <% end %>

<%= 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_uuid={@selected_profile_uuid} needs_profile_selection={@needs_profile_selection} /> <% end %>
<%= if length(@payment_options) > 1 do %> <% else %>
<% end %>
""" end defp profile_selector(assigns) do ~H"""
<%!-- Show info alert when multiple profiles exist without a default --%> <%= if @needs_profile_selection do %>
<.icon name="hero-information-circle" class="w-5 h-5" /> 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 .
<% end %> <%= for profile <- @billing_profiles do %>
<%!-- Edit button for selected profile --%> <%= if to_string(@selected_profile_uuid) == to_string(profile.uuid) 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" /> <% end %>
<% end %>
""" end defp billing_form(assigns) do ~H"""
First Name * <%= if @form_errors[:first_name] do %>

{@form_errors[:first_name]}

<% end %>
Last Name * <%= if @form_errors[:last_name] do %>

{@form_errors[:last_name]}

<% end %>
Email * <%= if @form_errors[:email] do %>

{@form_errors[:email]}

<% end %>
Phone
Address * <%= if @form_errors[:address_line1] do %>

{@form_errors[:address_line1]}

<% end %>
City * <%= if @form_errors[:city] do %>

{@form_errors[:city]}

<% end %>
Postal Code
Country * <%= if @form_errors[:country] do %>

{@form_errors[:country]}

<% end %>
""" end defp review_step(assigns) do selected_profile = if assigns.use_new_profile do nil else Enum.find( assigns.billing_profiles, &(to_string(&1.uuid) == to_string(assigns.selected_profile_uuid)) ) end assigns = assign(assigns, :selected_profile, selected_profile) ~H"""
<%!-- Payment Method --%>

Payment Method

<%= if length(@payment_options) > 1 do %> <% end %>
<%= if @selected_payment_option do %>
<.icon name={PaymentOption.icon_name(@selected_payment_option)} class="w-6 h-6 text-base-content/70" />
{@selected_payment_option.name}
<%= if @selected_payment_option.description do %>
{@selected_payment_option.description}
<% end %>
<% end %>
<%!-- Billing Info (only if billing is needed) --%> <%= if @needs_billing do %>

Billing Information

<%= if @use_new_profile do %>
{@billing_data["first_name"]} {@billing_data["last_name"]}
{[ @billing_data["address_line1"], @billing_data["city"], @billing_data["postal_code"], @billing_data["country"] ] |> Enum.filter(&(&1 && &1 != "")) |> Enum.join(", ")}
{@billing_data["email"]}
<%= if @billing_data["phone"] && @billing_data["phone"] != "" do %>
{@billing_data["phone"]}
<% end %> <% else %> <%= if @selected_profile do %>
{profile_display_name(@selected_profile)}
{profile_address(@selected_profile)}
<%= if @selected_profile.email do %>
{@selected_profile.email}
<% end %> <%= if @selected_profile.phone do %>
{@selected_profile.phone}
<% end %> <% end %> <% end %>
<% end %> <%!-- Shipping Info --%>

Shipping Method

<.link navigate={Routes.path("/cart")} class="btn btn-ghost btn-sm"> <.icon name="hero-pencil" class="w-4 h-4 mr-1" /> Change
<%= if @cart.shipping_method do %>
{@cart.shipping_method.name}
<%= if @cart.shipping_method.description do %>
{@cart.shipping_method.description}
<% end %>
<%= if Decimal.compare(@cart.shipping_amount || Decimal.new("0"), Decimal.new("0")) == :eq do %> FREE <% else %> {format_price(@cart.shipping_amount, @currency)} <% end %>
<% end %>
<%!-- Order Items --%>

Order Items

<.link navigate={Routes.path("/cart")} class="btn btn-ghost btn-sm"> <.icon name="hero-pencil" class="w-4 h-4 mr-1" /> Edit Cart
<%= for item <- @cart.items do %>
<%= if item.product_image do %>
{item.product_title}
<% else %>
<.icon name="hero-cube" class="w-8 h-8 opacity-30" />
<% end %>
{item.product_title}
<%= if item.selected_specs && item.selected_specs != %{} do %>
<%= for {key, value} <- item.selected_specs do %> {humanize_key(key)}: {value} <% end %>
<% end %>
Qty: {item.quantity} × {format_price(item.unit_price, @currency)}
{format_price(item.line_total, @currency)}
<% end %>
<%!-- Email Already Registered --%> <%= if @email_exists_error do %>
<.icon name="hero-user-circle" class="w-8 h-8 text-warning flex-shrink-0" />

Account already exists

An account with this email is already registered. Please log in to complete your order.

<.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
<% end %> <%!-- Error Message --%> <%= if @error_message do %>
<.icon name="hero-exclamation-circle" class="w-5 h-5" /> {@error_message}
<% end %> <%!-- Confirm Button --%>
<%= cond do %> <% @needs_billing -> %> <% length(@payment_options) > 1 -> %> <% true -> %>
<% end %>
""" end defp order_summary(assigns) do ~H"""

Order Summary

Subtotal ({@cart.items_count || 0} items) {format_price(@cart.subtotal, @currency)}
Shipping <%= if is_nil(@cart.shipping_method_uuid) do %> - <% else %> <%= if Decimal.compare(@cart.shipping_amount || Decimal.new("0"), Decimal.new("0")) == :eq do %> FREE <% else %> {format_price(@cart.shipping_amount, @currency)} <% end %> <% end %>
<%= if @cart.tax_amount && Decimal.compare(@cart.tax_amount, Decimal.new("0")) == :gt do %>
Tax {format_price(@cart.tax_amount, @currency)}
<% end %> <%= if @cart.discount_amount && Decimal.compare(@cart.discount_amount, Decimal.new("0")) == :gt do %>
Discount -{format_price(@cart.discount_amount, @currency)}
<% end %>
Total {format_price(@cart.total, @currency)}
""" end end