defmodule PhoenixKit.Modules.Shop.Web.OptionsSettings do @moduledoc """ Global product options settings LiveView. Allows administrators to manage global options that apply to all products. Supports both fixed and percentage-based price modifiers. """ use PhoenixKitWeb, :live_view alias PhoenixKit.Modules.Shop.Options alias PhoenixKit.Modules.Shop.OptionTypes alias PhoenixKit.Utils.Routes @impl true def mount(_params, _session, socket) do options = Options.get_global_options() socket = socket |> assign(:page_title, "Product Options") |> assign(:options, options) |> assign(:show_modal, false) |> assign(:editing_option, nil) |> assign(:form_data, initial_form_data()) |> assign(:supported_types, OptionTypes.supported_types()) |> assign(:modifier_types, OptionTypes.modifier_types()) {:ok, socket} end @impl true def handle_event("show_add_modal", _params, socket) do {:noreply, socket |> assign(:show_modal, true) |> assign(:editing_option, nil) |> assign(:form_data, initial_form_data())} end @impl true def handle_event("show_edit_modal", %{"key" => key}, socket) do option = Enum.find(socket.assigns.options, &(&1["key"] == key)) if option do form_data = %{ key: option["key"], label: option["label"], type: option["type"], options: option["options"] || [], required: option["required"] || false, unit: option["unit"] || "", affects_price: option["affects_price"] || false, modifier_type: option["modifier_type"] || "fixed", price_modifiers: option["price_modifiers"] || %{}, allow_override: option["allow_override"] || false, enabled: Map.get(option, "enabled", true) } {:noreply, socket |> assign(:show_modal, true) |> assign(:editing_option, option) |> assign(:form_data, form_data)} else {:noreply, socket} end end @impl true def handle_event("close_modal", _params, socket) do {:noreply, socket |> assign(:show_modal, false) |> assign(:editing_option, nil) |> assign(:form_data, initial_form_data())} end @impl true def handle_event("validate_form", %{"option" => params}, socket) do options = parse_options(params["options"]) form_data = %{ key: params["key"] || "", label: params["label"] || "", type: params["type"] || "text", options: options, required: params["required"] == "true", unit: params["unit"] || "", affects_price: params["affects_price"] == "true", modifier_type: params["modifier_type"] || "fixed", price_modifiers: parse_price_modifiers(params["price_modifiers"], options), allow_override: params["allow_override"] == "true" } # Auto-generate key from label if creating new form_data = if socket.assigns.editing_option == nil and form_data.key == "" do %{form_data | key: slugify_key(form_data.label)} else form_data end {:noreply, assign(socket, :form_data, form_data)} end @impl true def handle_event("toggle_affects_price", _params, socket) do form_data = socket.assigns.form_data updated = %{form_data | affects_price: !form_data.affects_price} # Initialize price modifiers with "0" for all options when enabling updated = if updated.affects_price and map_size(updated.price_modifiers) == 0 do modifiers = Map.new(updated.options, fn opt -> {opt, "0"} end) %{updated | price_modifiers: modifiers} else updated end {:noreply, assign(socket, :form_data, updated)} end @impl true def handle_event("set_modifier_type", %{"type" => type}, socket) do form_data = socket.assigns.form_data updated = %{form_data | modifier_type: type} {:noreply, assign(socket, :form_data, updated)} end @impl true def handle_event("toggle_allow_override", _params, socket) do form_data = socket.assigns.form_data updated = %{form_data | allow_override: !form_data.allow_override} {:noreply, assign(socket, :form_data, updated)} end @impl true def handle_event("save_option", %{"option" => params}, socket) do form_data = parse_form_params(params) opt = build_option(form_data) current = socket.assigns.options editing = socket.assigns.editing_option result = if editing do updated = Enum.map(current, fn o -> if o["key"] == editing["key"], do: Map.merge(o, opt), else: o end) Options.update_global_options(updated) else opt = Map.put(opt, "position", length(current)) Options.add_global_option(opt) end case result do {:ok, _} -> {:noreply, socket |> assign(:options, Options.get_global_options()) |> assign(:show_modal, false) |> assign(:editing_option, nil) |> assign(:form_data, initial_form_data()) |> put_flash(:info, if(editing, do: "Option updated", else: "Option created"))} {:error, reason} -> {:noreply, put_flash(socket, :error, "Error: #{reason}")} end end @impl true def handle_event("delete_option", %{"key" => key}, socket) do case Options.remove_global_option(key) do {:ok, _} -> {:noreply, socket |> assign(:options, Options.get_global_options()) |> put_flash(:info, "Option deleted")} {:error, reason} -> {:noreply, put_flash(socket, :error, "Error: #{reason}")} end end @impl true def handle_event("toggle_enabled", %{"key" => key}, socket) do current = socket.assigns.options updated = Enum.map(current, fn opt -> if opt["key"] == key do current_enabled = Map.get(opt, "enabled", true) Map.put(opt, "enabled", !current_enabled) else opt end end) case Options.update_global_options(updated) do {:ok, _} -> toggled = Enum.find(updated, &(&1["key"] == key)) label = if Map.get(toggled, "enabled", true), do: "enabled", else: "disabled" {:noreply, socket |> assign(:options, Options.get_global_options()) |> put_flash(:info, "Option #{key} #{label}")} {:error, reason} -> {:noreply, put_flash(socket, :error, "Error: #{reason}")} end end @impl true def handle_event("reorder_options", %{"ordered_ids" => ordered_keys}, socket) do current = socket.assigns.options # Reorder options based on new order reordered = ordered_keys |> Enum.with_index() |> Enum.map(fn {key, idx} -> opt = Enum.find(current, &(&1["key"] == key)) if opt, do: Map.put(opt, "position", idx), else: nil end) |> Enum.reject(&is_nil/1) case Options.update_global_options(reordered) do {:ok, _} -> {:noreply, assign(socket, :options, Options.get_global_options())} {:error, reason} -> {:noreply, put_flash(socket, :error, "Reorder failed: #{reason}")} end end @impl true def handle_event("add_option", _params, socket) do form_data = socket.assigns.form_data updated = %{form_data | options: form_data.options ++ [""]} {:noreply, assign(socket, :form_data, updated)} end @impl true def handle_event("remove_option", %{"index" => idx}, socket) do form_data = socket.assigns.form_data index = String.to_integer(idx) updated = %{form_data | options: List.delete_at(form_data.options, index)} {:noreply, assign(socket, :form_data, updated)} end @impl true def render(assigns) do ~H"""
<%!-- Header --%>
<.link navigate={Routes.path("/admin/shop/settings")} class="btn btn-outline btn-primary btn-sm absolute left-0 top-0" > <.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> Back

Product Options

Define global options that apply to all products

<%!-- Controls Bar --%>
<.icon name="hero-information-circle" class="w-4 h-4" /> Global options apply to all products. Categories can override or add their own.
<%!-- Options List --%>

<.icon name="hero-adjustments-horizontal" class="w-5 h-5" /> Global Options

{length(@options)} {if length(@options) == 1, do: "option", else: "options"}
<%= if @options == [] do %>
<.icon name="hero-adjustments-horizontal" class="w-16 h-16 mx-auto text-base-content/30 mb-4" />

No options defined yet

Add your first global option to get started

<% else %> <%!-- Table Header --%>
<%= for opt <- @options do %> <% enabled = Map.get(opt, "enabled", true) != false %>
<%!-- Content --%>
{opt["label"]} {opt["type"]} <%= if !enabled do %> Disabled <% end %> <%= if opt["required"] do %> Required <% end %> <%= if opt["unit"] do %> {opt["unit"]} <% end %> <%= if opt["affects_price"] do %> {opt["modifier_type"] || "fixed"} <%= if opt["allow_override"] do %> Override <% end %> <% end %>
{opt["key"]} <%= if opt["options"] && opt["options"] != [] do %> {format_options_with_modifiers(opt)} <% end %>
<%!-- Actions Column --%>
<% end %>
<% end %>
<%!-- Reference Section (collapsible) --%>
<.icon name="hero-book-open" class="w-4 h-4" /> Option Types Reference

Input Types

text number boolean select multiselect

Price Modifiers

fixed (+10) percent (+20%)

Enable "Allow Override" for per-product values

<%!-- Modal for Add/Edit Option --%> <%= if @show_modal do %> <% end %>
""" end # Private helpers defp initial_form_data do %{ key: "", label: "", type: "text", options: [], required: false, unit: "", affects_price: false, modifier_type: "fixed", price_modifiers: %{}, allow_override: false, enabled: true } end defp slugify_key(""), do: "" defp slugify_key(text) do text |> String.downcase() |> String.replace(~r/[^a-z0-9\s]/, "") |> String.replace(~r/\s+/, "_") |> String.replace(~r/_+/, "_") |> String.trim("_") end defp parse_form_params(params) do options = parse_options(params["options"]) %{ key: params["key"] || "", label: params["label"] || "", type: params["type"] || "text", options: options, required: params["required"] == "true", unit: params["unit"] || "", affects_price: params["affects_price"] == "true", modifier_type: params["modifier_type"] || "fixed", price_modifiers: parse_price_modifiers(params["price_modifiers"], options), allow_override: params["allow_override"] == "true" } end defp build_option(form_data) do key = if form_data.key == "", do: slugify_key(form_data.label), else: form_data.key %{ "key" => key, "label" => form_data.label, "type" => form_data.type, "required" => form_data.required } |> maybe_put_options(form_data) |> maybe_put_unit(form_data) |> maybe_put_price_modifiers(form_data) end defp maybe_put_options(opt, %{type: type, options: options}) when type in ["select", "multiselect"], do: Map.put(opt, "options", options) defp maybe_put_options(opt, _), do: opt defp maybe_put_unit(opt, %{unit: ""}), do: opt defp maybe_put_unit(opt, %{unit: unit}), do: Map.put(opt, "unit", unit) defp maybe_put_price_modifiers( opt, %{ type: type, affects_price: true, modifier_type: modifier_type, price_modifiers: mods, allow_override: allow_override } ) when type in ["select", "multiselect"] do opt |> Map.put("affects_price", true) |> Map.put("modifier_type", modifier_type) |> Map.put("price_modifiers", mods) |> Map.put("allow_override", allow_override) end defp maybe_put_price_modifiers(opt, _), do: Map.put(opt, "affects_price", false) defp parse_options(nil), do: [] defp parse_options(options) when is_map(options) do options # Filter out Phoenix LiveView's hidden _unused_ fields |> Enum.reject(fn {k, _v} -> String.starts_with?(k, "_unused") end) |> Enum.sort_by(fn {k, _v} -> case Integer.parse(k) do {num, ""} -> num _ -> 0 end end) |> Enum.map(fn {_k, v} -> v end) |> Enum.reject(&(&1 == "")) end defp parse_options(options) when is_list(options), do: options defp parse_options(_), do: [] defp parse_price_modifiers(nil, _options), do: %{} defp parse_price_modifiers(modifiers, options) when is_map(modifiers) do # Only keep modifiers for valid options, with valid decimal values Enum.reduce(options, %{}, fn opt, acc -> value = Map.get(modifiers, opt, "0") # Normalize the value to a valid decimal string normalized = normalize_price_modifier(value) Map.put(acc, opt, normalized) end) end defp parse_price_modifiers(_, _), do: %{} defp normalize_price_modifier(value) when is_binary(value) do case Decimal.parse(value) do {decimal, ""} -> Decimal.to_string(decimal) _ -> "0" end end defp normalize_price_modifier(_), do: "0" defp format_options_with_modifiers(%{ "affects_price" => true, "options" => options, "modifier_type" => modifier_type, "price_modifiers" => modifiers }) when is_list(options) and is_map(modifiers) do suffix = if modifier_type == "percent", do: "%", else: "" Enum.map_join(options, ", ", fn opt -> case Map.get(modifiers, opt) do nil -> opt "0" -> opt mod -> "#{opt} (+#{mod}#{suffix})" end end) end defp format_options_with_modifiers(%{ "affects_price" => true, "options" => options, "price_modifiers" => modifiers }) when is_list(options) and is_map(modifiers) do # Default to fixed for backward compatibility Enum.map_join(options, ", ", fn opt -> case Map.get(modifiers, opt) do nil -> opt "0" -> opt mod -> "#{opt} (+#{mod})" end end) end defp format_options_with_modifiers(%{"options" => options}) when is_list(options) do Enum.join(options, ", ") end defp format_options_with_modifiers(_), do: "" end