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}>
An account with this email is already registered. Please log in to complete your order.