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.Billing.Currency alias PhoenixKit.Modules.Languages.DialectMapper alias PhoenixKit.Modules.Shop alias PhoenixKit.Modules.Shop.Events alias PhoenixKit.Modules.Shop.ShippingMethod alias PhoenixKit.Modules.Shop.Translations @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_id = if user, do: user.id, else: nil user_uuid = if user, do: user.uuid, else: nil # Get or create cart {:ok, cart} = Shop.get_or_create_cart(user_id: user_id, 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_id" => item_id, "quantity" => quantity}, socket) do quantity = max(1, String.to_integer(quantity)) update_item_quantity(socket, item_id, quantity) end @impl true def handle_event("remove_item", %{"item_id" => item_id}, socket) do item = Enum.find(socket.assigns.cart.items, &(&1.uuid == item_id)) 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_id) -> {: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_id, quantity) do item = Enum.find(socket.assigns.cart.items, &(&1.uuid == item_id)) 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""" <.shop_layout {assigns}>
<%!-- Header --%>
<.link navigate={Shop.catalog_url(@current_language)} class="btn btn-outline btn-primary btn-sm shrink-0" > <.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> Continue Shopping

Shopping Cart

Review your items before checkout

<%!-- Cart Items --%>
<%= if @cart.items == [] do %>
<.icon name="hero-shopping-cart" class="w-16 h-16 mx-auto mb-4 opacity-30" />

Your cart is empty

Add some products to get started

<.link navigate={Shop.catalog_url(@current_language)} class="btn btn-primary"> Browse Products
<% else %>
<%= for item <- @cart.items do %> <% end %>
Product Quantity Price
<%= 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" > {item.product_title} <% else %>
{item.product_title}
<% 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" /> <% else %>
<.icon name="hero-cube" class="w-8 h-8 opacity-30" />
<% end %> <% end %>
<%= if item.product_slug do %> <.link navigate={product_item_url(item, @current_language)} class="hover:text-primary transition-colors" > {item.product_title} <% else %> {item.product_title} <% end %>
<%= if item.product_sku do %>
SKU: {item.product_sku}
<% end %> <%= if item.selected_specs && item.selected_specs != %{} do %>
<%= for {key, value} <- item.selected_specs do %> {humanize_key(key)}: {value} <% end %>
<% end %> <%= if item.compare_at_price && Decimal.compare(item.compare_at_price, item.unit_price) == :gt do %>
{format_price(item.compare_at_price, @currency)} On sale!
<% end %>
{format_price(item.line_total, @currency)}
{format_price(item.unit_price, @currency)} each
<% end %> <%!-- Shipping Section --%> <%= if @cart.items != [] do %>

Shipping Method

<%= if @shipping_methods == [] do %>
<.icon name="hero-exclamation-triangle" class="w-5 h-5" /> No shipping methods available for your selection
<% else %>
<%= for method <- @shipping_methods do %> <% end %>
<% end %>
<% end %>
<%!-- Order Summary --%>

Order Summary

Subtotal ({@cart.items_count || 0} items) {format_price(@cart.subtotal, @currency)}
Shipping <%= if is_nil(@cart.shipping_method_id) do %> Select method <% 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.discount_amount && Decimal.compare(@cart.discount_amount, Decimal.new("0")) == :gt do %>
Discount -{format_price(@cart.discount_amount, @currency)}
<% end %> <%= if @cart.tax_amount && Decimal.compare(@cart.tax_amount, Decimal.new("0")) == :gt do %>
Tax {format_price(@cart.tax_amount, @currency)}
<% end %>
Total {format_price(@cart.total, @currency)}
<%= if @cart.items != [] do %>

Secure checkout powered by PhoenixKit

<% end %>
""" 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 %> {render_slot(@inner_block)} <% else %> {render_slot(@inner_block)} <% end %> """ end # Private helpers defp generate_session_id do :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) 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) defp product_item_url(item, language) do base = DialectMapper.extract_base(language) Routes.path("/shop/product/#{item.product_slug}", locale: base) end end