lib/money/input/components.ex

if Code.ensure_loaded?(Phoenix.Component) do
  defmodule Money.Input.Components do
    @moduledoc """
    HEEx components for locale-aware money input.

    Two components ship from this module:

    * `money_input/1` — money input with a fixed currency or an
      embedded `currency_picker/1`.
    * `currency_picker/1` — first-class searchable currency picker
      (flag glyphs, recents in `localStorage`, mobile sheet
      variant, keyboard nav).

    Both render their HTML baseline server-side and degrade
    gracefully when JS is disabled. With the JS hook loaded
    (`priv/static/money_input.js`), they upgrade to live
    formatting and full picker behaviour.

    For a plain *number* input (no currency), use
    [`Localize.Inputs.Components.number_input/1`](https://hexdocs.pm/localize_inputs)
    from the sibling `localize_inputs` package.

    ## Setup

    Add the JS hooks in your `assets/js/app.js`:

        import { MoneyInput, CurrencyPicker } from "money_input"
        let Hooks = {}
        Hooks.MoneyInput = MoneyInput
        Hooks.CurrencyPicker = CurrencyPicker

    And install the AutoNumeric peer dep:

        npm install autonumeric

    See `Money.Input` for a full feature overview.

    """

    use Phoenix.Component

    alias Money.Input.Currency
    alias Money.Input.Components.Flags

    @doc """
    Locale-aware money input.

    Returns `Money.t/0` on form submission. Renders the currency
    symbol as an adornment outside the input — the user only
    types digits and a decimal separator.

    With `currency_picker={true}`, embeds the bundled
    `currency_picker/1` in the symbol-adornment position so the
    user can switch currencies. Currency-aware precision: USD
    accepts 2 decimals, JPY 0, BHD 3.

    ### Attributes

    * `:default_currency` — the currency used when the form value
      doesn't carry one and the user hasn't (yet) picked one. With
      the picker off this is the only currency the field accepts.
      With the picker on it pre-selects the trigger and acts as
      the fallback when the form value is blank.

    ### Examples

        <.money_input form={@form} field={:price} default_currency={:USD} />

        <.money_input
          form={@form}
          field={:price}
          default_currency={:USD}
          currency_picker={true}
          preferred_currencies={[:USD, :EUR, :GBP]}
        />

    """
    attr(:form, Phoenix.HTML.Form, required: true)
    attr(:field, :atom, required: true)
    attr(:value, :any, default: nil)
    attr(:locale, :string, default: nil)

    attr(:default_currency, :atom,
      default: nil,
      doc: "The currency used when the form value doesn't carry one. Pre-selects the picker."
    )

    attr(:currency_field, :atom, default: nil)

    attr(:min, :any, default: nil)
    attr(:max, :any, default: nil)
    attr(:align, :atom, default: :right, values: [:left, :right, :center])
    attr(:placeholder, :string, default: nil)
    attr(:symbol_position, :atom, default: :auto, values: [:auto, :prefix, :suffix])
    attr(:symbol_kind, :atom, default: :symbol, values: [:symbol, :iso, :narrow, :none])
    attr(:js, :boolean, default: true)
    attr(:class, :string, default: nil)
    attr(:input_class, :string, default: nil)
    attr(:symbol_class, :string, default: nil)

    attr(:currency_picker, :boolean, default: false)
    attr(:allowed_currencies, :list, default: nil)
    attr(:preferred_currencies, :list, default: [])
    attr(:rest, :global, include: ~w(disabled readonly required autofocus))

    def money_input(assigns) do
      assigns =
        assigns
        |> assign_money_field_names()
        |> assign_money_value()
        |> assign_money_locale_data()
        |> assign_symbol_position()

      ~H"""
      <div
        class={["money-input-wrapper", "money-input-money", @class]}
        data-money-input="money"
        data-locale={@locale_data.locale}
        data-currency={@effective_currency && to_string(@effective_currency)}
        data-decimal={@locale_data.decimal}
        data-group={@locale_data.group}
        data-number-system={@locale_data.number_system}
        data-minus={@locale_data.minus_sign}
        data-iso-digits={@locale_data.iso_digits}
        data-symbol-position={@symbol_position}
        data-min={value_attr(@min)}
        data-max={value_attr(@max)}
        phx-hook={if @js, do: "MoneyInput"}
        id={"#{@amount_id}-wrapper"}
      >
        <.money_adornment
          :if={@symbol_position == :prefix}
          symbol={@locale_data.symbol}
          symbol_class={@symbol_class}
          currency_picker={@currency_picker}
          currency={@effective_currency}
          locale={@locale_data.locale}
          allowed_currencies={@allowed_currencies}
          preferred_currencies={@preferred_currencies}
          currency_name={@currency_name}
          currency_id={@currency_id}
          id={"#{@amount_id}-symbol"}
        />
        <input
          type="text"
          inputmode="decimal"
          name={@amount_name}
          id={@amount_id}
          value={@formatted_value}
          class={["money-input-field", text_align_class(@align), @input_class]}
          autocomplete="off"
          dir="ltr"
          placeholder={@placeholder}
          aria-describedby={"#{@amount_id}-currency-name"}
          {@rest}
        />
        <%!-- When the picker is off, the currency travels alongside
              the amount as a hidden sibling. The server receives
              `params[field] = %{"amount" => ..., "currency" => ...}`
              either way, so `Money.Ecto.Composite.Type` and
              `Money.Input.Changeset.cast_money/3` cast in one step. --%>
        <input
          :if={!@currency_picker}
          type="hidden"
          name={@currency_name}
          id={@currency_id}
          value={@effective_currency && to_string(@effective_currency)}
        />
        <.money_adornment
          :if={@symbol_position == :suffix}
          symbol={@locale_data.symbol}
          symbol_class={@symbol_class}
          currency_picker={@currency_picker}
          currency={@effective_currency}
          locale={@locale_data.locale}
          allowed_currencies={@allowed_currencies}
          preferred_currencies={@preferred_currencies}
          currency_name={@currency_name}
          currency_id={@currency_id}
          id={"#{@amount_id}-symbol"}
        />
        <span id={"#{@amount_id}-currency-name"} class="sr-only">
          {currency_name(@locale_data.currency, @locale_data.locale)}
        </span>
      </div>
      """
    end

    attr(:symbol, :string, default: nil)
    attr(:symbol_class, :string, default: nil)
    attr(:currency_picker, :boolean, default: false)
    attr(:currency, :atom, default: nil)
    attr(:locale, :any, default: nil)
    attr(:allowed_currencies, :list, default: nil)
    attr(:preferred_currencies, :list, default: [])
    attr(:currency_name, :string, default: nil)
    attr(:currency_id, :string, default: nil)
    attr(:id, :string, required: true)

    defp money_adornment(assigns) do
      ~H"""
      <%= if @currency_picker do %>
        <.currency_picker
          name={@currency_name}
          input_id={@currency_id}
          current={@currency}
          allowed={@allowed_currencies}
          preferred={@preferred_currencies}
          locale={@locale}
          id={@id}
          class={Enum.join(["money-input-symbol", @symbol_class || ""], " ")}
        />
      <% else %>
        <span
          class={["money-input-symbol", @symbol_class]}
          aria-hidden="true"
        >{@symbol}</span>
      <% end %>
      """
    end

    @doc """
    First-class searchable currency picker.

    Renders as a trigger button (flag + ISO code). Clicking opens
    an overlay with a search input, recent selections (persisted
    in `localStorage`), pinned preferred currencies, and the full
    sorted currency list. On mobile, the overlay becomes a
    full-screen sheet.

    ### Examples

        <.currency_picker
          form={@form}
          field={:currency}
          current={:USD}
          preferred={[:USD, :EUR, :GBP, :JPY]}
        />

    """
    attr(:form, Phoenix.HTML.Form, default: nil)
    attr(:field, :atom, default: nil)

    attr(:name, :string,
      default: nil,
      doc:
        "Explicit hidden-input name. Overrides form+field. Used by `money_input/1` to inject a nested name like `price[currency]`."
    )

    attr(:input_id, :string,
      default: nil,
      doc: "Explicit id for the hidden value input."
    )

    attr(:current, :atom, required: true)
    attr(:allowed, :list, default: nil)
    attr(:preferred, :list, default: [])
    attr(:recents_limit, :integer, default: 5)
    attr(:locale, :any, default: nil)
    attr(:variant, :atom, default: :auto, values: [:auto, :dropdown, :sheet])
    attr(:id, :string, default: nil)
    attr(:class, :string, default: nil)
    attr(:button_class, :string, default: nil)
    attr(:overlay_class, :string, default: nil)
    attr(:row_class, :string, default: nil)

    def currency_picker(assigns) do
      assigns = assign_picker(assigns)

      ~H"""
      <div
        class={["currency-picker", @class]}
        id={@id}
        data-currency-picker
        data-locale={@locale_id}
        data-current={to_string(@current)}
        data-variant={to_string(@variant)}
        data-recents-limit={@recents_limit}
        data-preferred={Enum.map_join(@preferred, ",", &to_string/1)}
        phx-hook="CurrencyPicker"
      >
        <button
          type="button"
          class={["currency-picker-trigger", @button_class]}
          data-currency-picker-trigger
          aria-haspopup="listbox"
          aria-expanded="false"
        >
          <span class="currency-picker-flag" aria-hidden="true">{flag_for(@current)}</span>
          <span class="currency-picker-code">{@current}</span>
          <span class="currency-picker-caret" aria-hidden="true"></span>
        </button>
        <%= if @hidden_name do %>
          <input
            type="hidden"
            name={@hidden_name}
            id={@hidden_id}
            value={to_string(@current)}
            data-currency-picker-value
          />
        <% end %>
        <div
          class={["currency-picker-overlay", @overlay_class]}
          data-currency-picker-overlay
          role="dialog"
          aria-label="Choose currency"
          hidden
        >
          <div class="currency-picker-search-row">
            <input
              type="search"
              class="currency-picker-search"
              data-currency-picker-search
              placeholder="Search code, name, country, symbol…"
              aria-label="Filter currencies"
            />
            <button
              type="button"
              class="currency-picker-close"
              data-currency-picker-close
              aria-label="Close currency picker"
            >×</button>
          </div>
          <ul class="currency-picker-list" role="listbox" data-currency-picker-list>
            <%= for {section_label, rows} <- @sections do %>
              <%= if rows != [] do %>
                <li class="currency-picker-section" role="presentation">{section_label}</li>
                <%= for row <- rows do %>
                  <li
                    class={["currency-picker-row", @row_class]}
                    role="option"
                    tabindex="-1"
                    data-currency-picker-row
                    data-code={row.code}
                    data-name={row.name}
                    data-country={row.country}
                    data-symbol={row.symbol}
                    data-iso-digits={row.iso_digits}
                    aria-selected={if row.code == to_string(@current), do: "true"}
                  >
                    <span class="currency-picker-flag" aria-hidden="true">{row.flag}</span>
                    <span class="currency-picker-row-code">{row.code}</span>
                    <span class="currency-picker-row-name">{row.name}</span>
                    <span class="currency-picker-row-symbol">{row.symbol}</span>
                  </li>
                <% end %>
              <% end %>
            <% end %>
            <li class="currency-picker-empty" data-currency-picker-empty hidden>No matches</li>
          </ul>
        </div>
      </div>
      """
    end

    # ── Internal: shared assigns ──────────────────────────────

    defp assign_money_value(assigns) do
      explicit = assigns.value
      form_value = (assigns.form[assigns.field] || %{}).value
      raw = explicit || form_value

      {amount, currency_from_value} = extract_amount_and_currency(raw)
      effective_currency = currency_from_value || assigns.default_currency

      formatted = format_amount(amount, effective_currency, assigns.locale)

      assigns
      |> assign(:formatted_value, formatted)
      |> assign(:effective_currency, effective_currency)
    end

    # Format the amount portion only (no currency symbol) for the
    # input's `value` attribute. The symbol is rendered as a
    # separate adornment outside the input. `Money.to_string!`
    # with `currency_symbol: :none` does the locale-correct
    # rendering — separators, spacing, and currency-aware
    # fractional digits (JPY 0, USD 2, BHD 3).
    #
    # `Money.new/3` itself needs the locale to parse a
    # locale-formatted amount string (Path A fallback path) —
    # without it, `"1.234,56"` is read as `"1.23456"`.
    defp format_amount(nil, _currency, _locale), do: ""
    defp format_amount("", _currency, _locale), do: ""
    defp format_amount(_amount, nil, _locale), do: ""

    defp format_amount(amount, currency, locale) do
      case Money.new(currency, amount, locale: locale) do
        %Money{} = money ->
          Money.to_string!(money, locale: locale, currency_symbol: :none)

        # Unparseable amount (non-empty garbage like "abc"). Render
        # the input as blank — the surrounding template still keeps
        # the user's raw text in the actual form, so they can
        # correct it. Better than crashing the whole page.
        {:error, _} ->
          ""
      end
    end

    # money_input receives values in four shapes; we normalise to
    # `{amount, currency}` so the rest of the render path doesn't
    # have to branch.
    defp extract_amount_and_currency(nil), do: {nil, nil}
    defp extract_amount_and_currency(""), do: {nil, nil}
    defp extract_amount_and_currency(%Money{} = m), do: {m.amount, m.currency}
    defp extract_amount_and_currency(%Decimal{} = d), do: {d, nil}
    defp extract_amount_and_currency(v) when is_integer(v) or is_float(v), do: {v, nil}

    defp extract_amount_and_currency(%{} = map) do
      amount =
        map["amount"] || map[:amount] ||
          Map.get(map, "amount") || Map.get(map, :amount)

      currency =
        normalize_currency_code(
          map["currency"] || map[:currency] ||
            Map.get(map, "currency") || Map.get(map, :currency)
        )

      {amount, currency}
    end

    defp extract_amount_and_currency(value) when is_binary(value), do: {value, nil}
    defp extract_amount_and_currency(_), do: {nil, nil}

    defp normalize_currency_code(nil), do: nil
    defp normalize_currency_code(code) when is_atom(code), do: code

    defp normalize_currency_code(code) when is_binary(code) do
      try do
        String.to_existing_atom(String.upcase(code))
      rescue
        _ -> nil
      end
    end

    defp assign_money_field_names(assigns) do
      field_struct = assigns.form[assigns.field]
      base_name = field_struct.name
      base_id = field_struct.id

      assigns
      |> assign(:amount_name, "#{base_name}[amount]")
      |> assign(:currency_name, "#{base_name}[currency]")
      |> assign(:amount_id, "#{base_id}_amount")
      |> assign(:currency_id, "#{base_id}_currency")
      |> assign_new(:placeholder, fn -> nil end)
      |> assign_new(:class, fn -> nil end)
      |> assign_new(:input_class, fn -> nil end)
      |> assign_new(:symbol_class, fn -> nil end)
    end

    defp assign_money_locale_data(assigns) do
      locale = assigns[:locale] || Localize.get_locale()

      {:ok, locale_data} =
        Currency.currency_for_locale(locale,
          currency: assigns.effective_currency,
          symbol_kind: assigns.symbol_kind
        )

      assigns
      |> assign(:locale, locale)
      |> assign(:locale_data, locale_data)
    end

    defp assign_symbol_position(assigns) do
      position =
        case assigns.symbol_position do
          :auto -> assigns.locale_data.symbol_position || :prefix
          explicit -> explicit
        end

      assign(assigns, :symbol_position, position)
    end

    defp assign_picker(assigns) do
      allowed = assigns.allowed || curated_currencies()
      preferred = assigns.preferred || []
      locale_id = locale_id(assigns.locale)

      {:ok, locale_data} = Currency.currency_for_locale(locale_id)

      preferred_rows =
        preferred
        |> Enum.map(&currency_row(&1, locale_id))
        |> Enum.reject(&is_nil/1)

      all_rows =
        allowed
        |> Enum.reject(&(&1 in preferred))
        |> Enum.map(&currency_row(&1, locale_id))
        |> Enum.reject(&is_nil/1)
        |> Enum.sort_by(& &1.name)

      sections = [
        {"Preferred", preferred_rows},
        {"All currencies", all_rows}
      ]

      id = assigns[:id] || "currency-picker-#{System.unique_integer([:positive])}"

      # The hidden value input is named via three priority levels:
      # 1) an explicit `name=` attr (used when embedded in money_input
      #    to inject a nested name like `price[currency]`),
      # 2) a form+field pair (standalone use),
      # 3) nothing — the picker is purely client-side state.
      {hidden_name, hidden_id} =
        case {assigns[:name], assigns[:form], assigns[:field]} do
          {explicit, _, _} when is_binary(explicit) ->
            {explicit, assigns[:input_id] || "#{id}-value"}

          {_, form, field} when not is_nil(form) and not is_nil(field) ->
            {Phoenix.HTML.Form.input_name(form, field), "#{id}-value"}

          _ ->
            {nil, nil}
        end

      assigns
      |> assign(:sections, sections)
      |> assign(:locale_id, locale_data.locale)
      |> assign(:id, id)
      |> assign(:hidden_name, hidden_name)
      |> assign(:hidden_id, hidden_id)
      |> assign_new(:class, fn -> nil end)
      |> assign_new(:button_class, fn -> nil end)
      |> assign_new(:overlay_class, fn -> nil end)
      |> assign_new(:row_class, fn -> nil end)
    end

    defp currency_row(code, _locale_id) do
      case Money.Currency.currency_for_code(code) do
        {:ok, currency} ->
          %{
            code: to_string(code),
            name: to_string(currency.name),
            country: Flags.country_for(code) || "",
            symbol: currency.symbol,
            flag: flag_for(code),
            iso_digits: currency.iso_digits
          }

        _ ->
          nil
      end
    end

    defp curated_currencies do
      ~w(USD EUR GBP JPY CHF CAD AUD NZD CNY HKD SGD KRW INR BRL MXN ZAR
         SEK NOK DKK PLN CZK HUF RON TRY RUB ILS AED SAR EGP THB IDR PHP
         MYR VND TWD UAH ARS COP PEN CLP PKR BDT NGN KES KZT MAD CHF BHD KWD)a
      |> Enum.uniq()
    end

    defp locale_id(nil), do: Localize.get_locale()
    defp locale_id(locale), do: locale

    defp value_attr(nil), do: nil
    defp value_attr(value), do: to_string(value)

    defp text_align_class(:left), do: "text-left"
    defp text_align_class(:center), do: "text-center"
    defp text_align_class(:right), do: "text-right"

    defp currency_name(nil, _locale), do: ""
    defp currency_name(%{name: name}, _locale) when is_binary(name), do: name
    defp currency_name(_, _), do: ""

    defp flag_for(code) when is_atom(code), do: Flags.flag_for(code)
    defp flag_for(_), do: "🏳"
  end
end