# `Money.Input.Visualizer` is a `Plug.Router`. It is only defined
# when `:plug` is available (i.e., when the consumer adds
# `{:plug, "~> 1.15"}` to their deps). When `:plug` isn't
# installed, the module simply doesn't exist — calling any
# function on it raises the standard `UndefinedFunctionError`,
# which is the conventional Elixir signal for "you're missing a
# dependency."
if Code.ensure_loaded?(Plug.Router) do
defmodule Money.Input.Visualizer do
@moduledoc """
A web-based visualizer for `Money.Input`.
This module is a `Plug.Router` that can be mounted inside a
Phoenix or Plug application, or run standalone during
development via `Money.Input.Visualizer.Standalone`.
## Views
* `/input` — interactive number and money input demo. Pick
a locale + currency, type a value, submit, and see the
parsed `Decimal`/`Money`, the canonical wire form, the
blur-formatted output, and validator results.
* `/parse` — cross-locale parsing table. One input string,
every locale, side-by-side.
* `/format` — cross-locale formatting table. One parsed
value, every locale, side-by-side.
* `/locale` — locale display data: decimal/grouping
separators, native digit system, currency-symbol position.
This is the snapshot the JS hook would read from
`data-` attributes.
## Mounting in Phoenix
In your `router.ex`:
forward "/money-input", Money.Input.Visualizer
## Running standalone
Money.Input.Visualizer.Standalone.start(port: 4002)
## Optional dependencies
The visualizer pulls in `:plug` (required for the router) and
`:bandit` (only used by the standalone helper). Both are
declared `optional: true` in this library's `mix.exs`, so you
must add them to your own project's deps to use the
visualizer:
{:plug, "~> 1.15"},
{:bandit, "~> 1.5"}
## Enable flag
The visualizer module is always compiled when `:plug` is
present, but `Money.Input.Visualizer.Standalone.start/1`
refuses to start unless `:ex_money_input, :visualizer` is set
to `true` in config, or `enabled: true` is passed
explicitly. Mounting under `forward/2` in a Phoenix router
is not gated — the host app is the one who decided to
expose it.
"""
use Plug.Router
plug(Plug.Logger, log: :debug)
plug(:match)
plug(Plug.Parsers, parsers: [:urlencoded], pass: ["text/*"])
plug(:dispatch)
alias Money.Input.Visualizer.Assets
alias Money.Input.Visualizer.FormatView
alias Money.Input.Visualizer.InputView
alias Money.Input.Visualizer.LocaleView
alias Money.Input.Visualizer.ParseView
get "/" do
base = base_path(conn)
conn
|> Plug.Conn.put_resp_header("location", base <> "/input")
|> Plug.Conn.send_resp(302, "")
end
get "/input" do
params = parse_params(conn, :input)
html(conn, InputView.render(params, base_path(conn)))
end
get "/parse" do
params = parse_params(conn, :parse)
html(conn, ParseView.render(params, base_path(conn)))
end
get "/format" do
params = parse_params(conn, :format)
html(conn, FormatView.render(params, base_path(conn)))
end
get "/locale" do
params = parse_params(conn, :locale)
html(conn, LocaleView.render(params, base_path(conn)))
end
get "/assets/style.css" do
conn
|> Plug.Conn.put_resp_content_type("text/css")
|> Plug.Conn.put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> Plug.Conn.send_resp(200, Assets.css())
end
get "/assets/money_input.css" do
conn
|> Plug.Conn.put_resp_content_type("text/css")
|> Plug.Conn.put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> Plug.Conn.send_resp(200, Assets.money_input_css())
end
get "/assets/money_input.js" do
conn
|> Plug.Conn.put_resp_content_type("application/javascript")
|> Plug.Conn.put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> Plug.Conn.send_resp(200, Assets.money_input_js())
end
get "/assets/logo.png" do
conn
|> Plug.Conn.put_resp_content_type("image/png")
|> Plug.Conn.put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> Plug.Conn.send_resp(200, Assets.logo_png())
end
match _ do
send_resp(conn, 404, "Not found")
end
# ---- helpers ---------------------------------------------------------
defp html(conn, iodata) do
conn
|> Plug.Conn.put_resp_content_type("text/html")
|> Plug.Conn.send_resp(200, IO.iodata_to_binary(iodata))
end
defp base_path(%Plug.Conn{script_name: []}), do: ""
defp base_path(%Plug.Conn{script_name: segments}), do: "/" <> Enum.join(segments, "/")
defp parse_params(%Plug.Conn{} = conn, view),
do: parse_params(conn.params, view, conn.assigns)
defp parse_params(params, :input, assigns) do
deployment_default = default_locale(assigns)
locale = param_locale(params, "locale", deployment_default)
%{
locale: locale,
deployment_default_locale: deployment_default,
default_currency:
param_currency(params, "default_currency", default_currency_for(locale)),
money_input: blank_default(Map.get(params, "money_input"), nil),
picker: picker_default(params),
preferred_currencies: preferred_currencies(params)
}
end
defp parse_params(params, :parse, assigns) do
locale = param_locale(params, "locale", default_locale(assigns))
%{
input: blank_default(Map.get(params, "input"), "1,234.56"),
currency: param_currency(params, "currency", default_currency_for(locale)),
mode: atom_default(Map.get(params, "mode"), [:number, :money], :number)
}
end
defp parse_params(params, :format, assigns) do
locale = param_locale(params, "locale", default_locale(assigns))
%{
amount: blank_default(Map.get(params, "amount"), "1234567.89"),
currency: param_currency(params, "currency", default_currency_for(locale)),
mode: atom_default(Map.get(params, "mode"), [:number, :money], :money)
}
end
defp parse_params(params, :locale, assigns) do
locale = param_locale(params, "locale", default_locale(assigns))
%{
currency: param_currency(params, "currency", default_currency_for(locale))
}
end
defp param_locale(params, key, default) do
case Map.get(params, key) do
nil ->
default
"" ->
default
value when is_binary(value) ->
# Reject locales we can't load — the alternative is a 500
# when the view layer tries to resolve symbols for an
# unknown tag. Falling back keeps the visualizer alive.
validate_locale(value) || default
end
end
defp param_currency(params, key, default) do
case Map.get(params, key) do
nil ->
to_atom_or(default, nil)
"" ->
nil
value when is_binary(value) ->
to_atom_or(value, to_atom_or(default, nil))
end
end
defp to_atom_or(nil, fallback), do: fallback
defp to_atom_or(value, fallback) when is_binary(value) do
try do
String.to_existing_atom(value)
rescue
ArgumentError -> fallback
end
end
defp blank_default(nil, default), do: default
defp blank_default("", default), do: default
defp blank_default(value, _), do: value
# Locale priority for the visualizer's defaults:
#
# 1. `?locale=…` URL param — handled at the call site
# by `param_locale/3`.
#
# 2. `conn.assigns[:locale]` — what an upstream Phoenix
# locale plug (e.g. one calling `Localize.put_locale/1`
# based on the user's session) typically sets. In a
# forward-mounted visualizer this is the host app's
# idea of the current user's locale.
#
# 3. `Localize.get_locale/0` — the per-process or
# application-level default. Used in the standalone
# visualizer where no upstream plug runs.
#
# The visualizer requires zero configuration to run: `:en`
# is always pre-compiled by Localize and resolves to USD,
# so the default flow works out of the box. Other locales
# need to be pre-compiled OR
# `config :localize, allow_runtime_locale_download: true`,
# but that's only relevant once the user picks an exotic
# locale — and the helpers below degrade gracefully (no
# crash, fall back to `:en`/USD) when an unavailable locale
# arrives.
defp default_locale(assigns) do
# Assigns from a host plug may carry anything — validate
# them before trusting. `Localize.get_locale/0` is the
# final authority on "the default locale" (it's the
# application-level or per-process default, never a hard-
# coded value) so we don't validate it again or guard with
# a hardcoded fallback like `"en"`.
validate_locale(stringify_locale(assigns[:locale])) ||
stringify_locale(safe_get_locale())
end
defp safe_get_locale do
Localize.get_locale()
rescue
_ -> nil
end
defp stringify_locale(nil), do: nil
defp stringify_locale(locale) when is_binary(locale), do: locale
defp stringify_locale(locale) when is_atom(locale), do: Atom.to_string(locale)
defp stringify_locale(%{canonical_locale_id: id}) when is_binary(id), do: id
defp stringify_locale(_), do: nil
# Confirm the locale is something Localize can resolve before
# we hand it to the view layer. If validation fails (unknown
# tag, download disabled, etc.) we return `nil` so the caller
# falls through to the next priority level. This keeps the
# visualizer alive when a host app passes a stale or exotic
# locale via `conn.assigns[:locale]`.
defp validate_locale(nil), do: nil
defp validate_locale(locale) do
case Localize.validate_locale(locale) do
{:ok, _tag} -> locale
_ -> nil
end
rescue
_ -> nil
end
# Returns the locale's natural ISO 4217 tender as a string
# (en-AU → "AUD", ja-JP → "JPY"). When the lookup misses for
# the given locale, fall back through `Localize.get_locale/0`
# rather than a hard-coded currency — "the default locale's
# currency" is the right semantics, whatever that locale
# happens to be in this deployment.
defp default_currency_for(locale) do
with :error <- lookup_currency(locale) do
case lookup_currency(safe_get_locale()) do
:error -> nil
code -> code
end
end
end
defp lookup_currency(nil), do: :error
defp lookup_currency(locale) do
case Localize.Currency.current_currency_from_locale(locale) do
{:ok, code} when is_atom(code) -> Atom.to_string(code)
_ -> :error
end
rescue
_ -> :error
catch
_, _ -> :error
end
# Defaults the currency picker on. After a form submission we
# trust whatever the checkbox sent (absent = unchecked = off);
# on a fresh load (no `submitted` hidden field), we turn it on
# so a developer landing on the visualizer sees the picker
# immediately.
defp picker_default(params) do
if Map.has_key?(params, "submitted") do
truthy?(Map.get(params, "picker"))
else
true
end
end
defp preferred_currencies(params) do
case Map.get(params, "preferred") do
nil -> ~w(USD EUR GBP JPY CHF)a
"" -> ~w(USD EUR GBP JPY CHF)a
value when is_binary(value) -> parse_currency_list(value)
end
end
defp parse_currency_list(value) do
value
|> String.split([",", " ", "\n"], trim: true)
|> Enum.map(&String.upcase/1)
|> Enum.map(&to_atom_or(&1, nil))
|> Enum.reject(&is_nil/1)
end
defp truthy?(nil), do: false
defp truthy?("0"), do: false
defp truthy?("false"), do: false
defp truthy?(""), do: false
defp truthy?(_), do: true
defp atom_default(nil, _allowed, default), do: default
defp atom_default(value, allowed, default) when is_binary(value) do
atom =
try do
String.to_existing_atom(value)
rescue
ArgumentError -> default
end
if atom in allowed, do: atom, else: default
end
defp atom_default(_, _, default), do: default
end
end