Packages

phoenix_kit

2.28.2
2.62.2 2.62.1 2.62.0 2.61.0 2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web components multilang_form.ex
Raw

lib/phoenix_kit_web/components/multilang_form.ex

defmodule PhoenixKitWeb.Components.MultilangForm do
  @moduledoc """
  Shared multilang form components and helpers for PhoenixKit modules.

  Provides the language tab switcher UI, skeleton loading placeholders,
  translatable field components, and Elixir-side helpers for merging
  multilang data in LiveView forms.

  Designed for two main use cases:

  1. **Whole-form translation** — wrap all translatable fields in a card with
     language tabs. The tab bar, skeleton placeholders, and field wrappers
     are handled automatically.

  2. **Single-field translation** — drop a `<.translatable_field>` into any
     form to make one field translatable, with no tab UI required (the caller
     manages `current_lang` however they like).

  ## Usage in a LiveView

  ### Mount

      import PhoenixKitWeb.Components.MultilangForm

      def mount(params, session, socket) do
        # ... load your record and changeset ...
        {:ok, mount_multilang(socket)}
      end

  ### Events

  `mount_multilang/2` auto-handles the `"switch_language"` event, so you do
  NOT need a `handle_event("switch_language", …)` clause — you only wire your
  own form events:

      def handle_event("validate", %{"record" => params}, socket) do
        params = merge_translatable_params(params, socket, ["name", "description"],
          changeset: socket.assigns.changeset)
        changeset = MySchema.changeset(socket.assigns.record, params)
        {:noreply, assign(socket, :changeset, changeset)}
      end

  ## Storage caveat — the helper OWNS the `data` column

  `merge_translatable_params/4` (and `put_language_data/3`) store translations
  by RESTRUCTURING the schema's `data` JSONB into a multilang shape
  (`%{"_primary_language" => "en", "en" => %{…}, "et" => %{…}}`). On a schema
  whose `data` column already holds unrelated keys (e.g. per-record settings),
  this MOVES those keys into the primary-language sub-map and every top-level
  reader breaks. In that case do NOT point `merge_translatable_params` at
  `data`: keep translations in an isolated sub-key and use `translatable_field`
  in its settings-translations mode (`secondary_name` + `lang_data_key`) with
  your own small merge. See the `translatable_field` docs.

  ### Template — whole-form translation

      <.multilang_tabs
        multilang_enabled={@multilang_enabled}
        language_tabs={@language_tabs}
        current_lang={@current_lang}
      />

      <.multilang_fields_wrapper
        multilang_enabled={@multilang_enabled}
        current_lang={@current_lang}
      >
        <.translatable_field
          field_name="name"
          form_prefix="catalogue"
          changeset={@changeset}
          schema_field={:name}
          multilang_enabled={@multilang_enabled}
          current_lang={@current_lang}
          primary_language={@primary_language}
          lang_data={@lang_data}
          label={gettext("Name")}
          required
        />
      </.multilang_fields_wrapper>

  ### Template — single-field translation (no tabs needed)

      <.translatable_field
        field_name="description"
        form_prefix="product"
        changeset={@changeset}
        schema_field={:description}
        multilang_enabled={@multilang_enabled}
        current_lang={@current_lang}
        primary_language={@primary_language}
        lang_data={@lang_data}
        label={gettext("Description")}
        type="textarea"
        rows={5}
      />
  """

  use Phoenix.Component
  use Gettext, backend: PhoenixKitWeb.Gettext

  import PhoenixKitWeb.Components.Core.Icon
  import PhoenixKitWeb.Components.Core.FormFieldError, only: [error: 1]
  import PhoenixKitWeb.Components.Core.Input, only: [translate_error: 1]
  import PhoenixKitWeb.Components.LanguageSwitcher, only: [language_switcher: 1]

  alias Phoenix.LiveView.JS
  # PhoenixKit.Utils.Multilang is in an external package — referenced by full name
  # with Code.ensure_loaded?/rescue guards throughout this module.

  # ═══════════════════════════════════════════════════════════════════
  # Mount & Event Helpers
  # ═══════════════════════════════════════════════════════════════════

  @doc """
  Adds multilang assigns to the socket. Call from `mount/3`.

  Adds: `:multilang_enabled`, `:primary_language`, `:current_lang`,
  `:language_tabs`, `:show_multilang_tabs`, `:switching_lang` (no-op
  compat assign — kept because consumer templates pass it through to
  the wrapper).

  Also attaches two internal LiveView hooks so consumers don't have to
  wire the language-switching protocol by hand:

    * a `:handle_info` hook for the debounced language-switch timer, and
    * a `:handle_event` hook for the `"switch_language"` event that
      `<.multilang_tabs>` pushes — it calls `handle_switch_language/2`
      and halts, so **you no longer need to add**
      `def handle_event("switch_language", …)` yourself. Forgetting that
      clause used to crash the LiveView on the first tab click.

  ## Options

    * `:auto_switch_language` — when `false`, skips the `"switch_language"`
      event hook so you can handle the event yourself (e.g. to switch
      language immediately without the debounce). Default `true`.
  """
  def mount_multilang(socket, opts \\ []) do
    multilang_enabled = multilang_enabled?()
    primary_language = if multilang_enabled, do: safe_primary_language(), else: nil

    language_tabs = if(multilang_enabled, do: safe_build_language_tabs(), else: [])

    socket
    |> Phoenix.Component.assign(
      multilang_enabled: multilang_enabled,
      primary_language: primary_language,
      current_lang: primary_language,
      language_tabs: language_tabs,
      show_multilang_tabs: multilang_enabled and length(language_tabs) > 1,
      # Kept for backwards compat with consumer templates that still pass
      # `switching_lang={@switching_lang}` to `<.multilang_fields_wrapper>`.
      # The wrapper no longer reads it — visibility is client-side JS.
      switching_lang: false
    )
    |> attach_multilang_hooks(opts)
  end

  # Attaches the internal hooks. Both are idempotent-by-name within a
  # process — re-running `mount_multilang/2` (e.g. across reconnects) with
  # the same hook ids simply replaces the prior callbacks, so no guard is
  # needed. `attach_hook/4` only works on LiveView sockets (not
  # LiveComponents), so each attach is guarded — a LiveComponent consumer
  # would need to wire the handlers itself.
  defp attach_multilang_hooks(socket, opts) do
    socket = attach_apply_lang_hook(socket)

    if Keyword.get(opts, :auto_switch_language, true),
      do: attach_switch_language_hook(socket),
      else: socket
  end

  # Intercepts the debounced `{:__multilang_apply_lang__, lang}` message so
  # the consumer needs no `handle_info/2` clause. Other messages pass
  # through with `{:cont, socket}`.
  defp attach_apply_lang_hook(socket) do
    Phoenix.LiveView.attach_hook(
      socket,
      :__phoenix_kit_multilang_apply_lang__,
      :handle_info,
      fn
        {:__multilang_apply_lang__, lang_code}, socket ->
          {:halt, handle_multilang_apply_lang(socket, lang_code)}

        _msg, socket ->
          {:cont, socket}
      end
    )
  rescue
    ArgumentError -> socket
  end

  # Intercepts the `"switch_language"` event that `<.multilang_tabs>`
  # pushes, so the consumer needs no `handle_event/3` clause. Halting stops
  # propagation to the consumer's own callbacks; every other event passes
  # through with `{:cont, socket}`.
  defp attach_switch_language_hook(socket) do
    Phoenix.LiveView.attach_hook(
      socket,
      :__phoenix_kit_multilang_switch_language__,
      :handle_event,
      fn
        "switch_language", %{"lang" => lang_code}, socket ->
          {:halt, handle_switch_language(socket, lang_code)}

        _event, _params, socket ->
          {:cont, socket}
      end
    )
  rescue
    ArgumentError -> socket
  end

  @doc """
  Refreshes multilang assigns after external changes (e.g. entity schema update).

  Unlike `mount_multilang/1`, this preserves `current_lang` when it's still valid,
  and resets it to the primary language if it was removed.
  """
  def refresh_multilang(socket) do
    multilang_enabled = multilang_enabled?()
    primary_language = if multilang_enabled, do: safe_primary_language(), else: nil
    language_tabs = if multilang_enabled, do: safe_build_language_tabs(), else: []

    current_lang = socket.assigns[:current_lang]
    enabled_langs = if multilang_enabled, do: safe_enabled_languages(), else: []

    current_lang =
      cond do
        not multilang_enabled -> nil
        current_lang not in enabled_langs -> primary_language
        true -> current_lang
      end

    Phoenix.Component.assign(socket,
      multilang_enabled: multilang_enabled,
      primary_language: primary_language,
      current_lang: current_lang,
      language_tabs: language_tabs,
      show_multilang_tabs: multilang_enabled and length(language_tabs) > 1
    )
  end

  @doc """
  Handles the `"switch_language"` event. Call from `handle_event/3`.

  Returns a socket that **defers** applying `:current_lang` via a short
  trailing debounce (150 ms). Rapid click-through (EN → JA → FR → DE)
  keeps rescheduling the timer; only the last click actually updates
  `:current_lang` and triggers a content re-render. Without this, every
  intermediate event caused its own server render and the client
  briefly flashed each language's content before landing on the final
  one.

  Nothing to do on the consumer's end — `mount_multilang/1` attaches a
  `:handle_info` hook via `Phoenix.LiveView.attach_hook/4` that
  intercepts the internal `{:__multilang_apply_lang__, lang}` message
  and applies the language transparently. Calling code never sees the
  message.

  Ignores unknown language codes.
  """
  @multilang_debounce_ms 150
  # The timer ref lives in `socket.private` (not `socket.assigns`) so
  # that storing it doesn't trigger a render+diff cycle that would fight
  # the client-side skeleton/fields visibility toggles. Private state
  # is per-socket — visible only inside the LV process — and survives
  # the LV's lifecycle the same way assigns do, but with zero diff cost.
  @multilang_timer_private_key :__phoenix_kit_multilang_timer__

  def handle_switch_language(socket, lang_code) do
    if lang_code in safe_enabled_languages() do
      socket = cancel_multilang_timer(socket)

      timer_ref =
        Process.send_after(
          self(),
          {:__multilang_apply_lang__, lang_code},
          @multilang_debounce_ms
        )

      # Skeleton/fields visibility is driven entirely by
      # `switch_lang_js/2`'s client-side class toggles. The server just
      # holds off applying the new `current_lang` until the debounce
      # fires, which is what causes the final morphdom swap that
      # restores the fields (with new-lang content) and hides the
      # skeleton again.
      Phoenix.LiveView.put_private(socket, @multilang_timer_private_key, timer_ref)
    else
      socket
    end
  end

  @doc """
  Applies a debounced language change. Call from `handle_info/2` when
  `{:__multilang_apply_lang__, lang_code}` is received.
  """
  def handle_multilang_apply_lang(socket, lang_code) do
    socket
    |> Phoenix.LiveView.put_private(@multilang_timer_private_key, nil)
    |> Phoenix.Component.assign(:current_lang, lang_code)
  end

  defp cancel_multilang_timer(socket) do
    case Map.get(socket.private, @multilang_timer_private_key) do
      ref when is_reference(ref) ->
        Process.cancel_timer(ref)
        Phoenix.LiveView.put_private(socket, @multilang_timer_private_key, nil)

      _ ->
        socket
    end
  end

  @doc """
  Merges translatable field params into the multilang `data` JSONB structure.

  Takes the raw form params, the socket, and a list of translatable field names
  (the DB column names, e.g. `["name", "description"]`). Returns updated params
  with the `"data"` key set to the merged multilang structure.

  On primary language tabs, reads from `params["name"]`.
  On secondary language tabs, reads from `params["lang_name"]`.

  Also preserves primary language values for non-translatable fields when on
  secondary tabs via the `preserve_fields` option.

  ## Options

    * `:changeset` — the current changeset (required)
    * `:preserve_fields` — map of `%{"field_name" => :schema_field}` for fields
      that should keep their primary language DB column value on secondary tabs.
      Defaults to `%{}`.
  """
  def merge_translatable_params(params, socket, translatable_fields, opts \\ []) do
    changeset = Keyword.fetch!(opts, :changeset)
    preserve_fields = Keyword.get(opts, :preserve_fields, %{})
    assigns = socket.assigns
    current_lang = assigns[:current_lang]
    primary = assigns[:primary_language]

    params =
      if assigns[:multilang_enabled] do
        form_data = extract_translatable_data(params, translatable_fields, current_lang, primary)
        final_data = do_merge_multilang_data(changeset, current_lang, form_data, assigns)
        Map.put(params, "data", final_data)
      else
        params
      end

    params
    |> preserve_translatable_primaries(changeset, assigns, translatable_fields)
    |> do_preserve_primary_fields(changeset, assigns, preserve_fields)
  end

  # On a secondary language tab the form submits only `lang_<field>` —
  # the PRIMARY columns are absent from params, so a validate/save that
  # rebuilds the changeset from the pristine struct silently drops the
  # primary text typed earlier (fatal on :new, where nothing else holds
  # it). The translatable fields ALWAYS need this preservation, so it no
  # longer depends on each consumer remembering `preserve_fields`.
  defp preserve_translatable_primaries(params, changeset, assigns, translatable_fields) do
    if assigns[:multilang_enabled] && assigns[:current_lang] != assigns[:primary_language] do
      Enum.reduce(translatable_fields, params, fn field, acc ->
        preserve_field_value(acc, changeset, field, safe_existing_atom(field))
      end)
    else
      params
    end
  end

  defp safe_existing_atom(field) when is_binary(field) do
    String.to_existing_atom(field)
  rescue
    ArgumentError -> nil
  end

  defp safe_existing_atom(field) when is_atom(field), do: field

  @doc """
  Injects a DB column value into the JSONB `data` field for multilang storage.

  This handles the common pattern where a field exists both as a top-level DB
  column (for queries/sorting) and inside the JSONB `data` (for translations).

  On the primary language tab, reads from `params[field_name]` (the DB column input).
  On secondary language tabs, reads from `params["lang_" <> field_name]` (the translation input).

  The value is stored in the data map under `"_" <> field_name` (e.g. `"_title"`).

  If no value is submitted (field not in form), preserves the existing value from
  the changeset's JSONB data.

  ## Requirements

  `assigns` must contain:
  - `:multilang_enabled` — boolean
  - `:primary_language` — the primary language code
  - `:changeset` — an `Ecto.Changeset` with a `:data` field (JSONB)

  ## Examples

      # In handle_event("validate", ...)
      form_data =
        form_data
        |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns)
        |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns)
  """
  def inject_db_field_into_data(form_data, field_name, params, current_lang, assigns) do
    if assigns[:multilang_enabled] == true do
      primary = assigns[:primary_language]

      value =
        if current_lang == primary,
          do: params[field_name],
          else: params["lang_#{field_name}"]

      data_key = "_#{field_name}"

      if is_binary(value) do
        Map.put(form_data, data_key, value)
      else
        # No value submitted — preserve existing from JSONB data
        existing_data = safe_get_changeset_data(assigns.changeset)

        case PhoenixKit.Utils.Multilang.get_raw_language_data(existing_data, current_lang) do
          %{^data_key => existing} -> Map.put(form_data, data_key, existing)
          _ -> form_data
        end
      end
    else
      form_data
    end
  end

  @doc """
  Merges language-specific validated data into the full multilang JSONB structure.

  Reads existing data from the changeset's `:data` field, then merges the new
  `validated_data` for the given `lang_code`.

  Handles three cases:
  - Multilang enabled: uses `PhoenixKit.Utils.Multilang.put_language_data/3`
  - Multilang disabled but data has multilang structure: preserves translations
  - Flat data, no multilang: passes through as-is

  The `changeset` must be an `Ecto.Changeset` with a `:data` field.
  `assigns` must contain `:multilang_enabled`.
  """
  def merge_multilang_data(changeset, lang_code, validated_data, assigns) do
    do_merge_multilang_data(changeset, lang_code, validated_data, assigns)
  end

  @doc """
  Gets the raw language data for the current language from a changeset.

  Use this in templates to read override-only values for secondary language tabs.
  Returns `%{}` when multilang is disabled.
  """
  def get_lang_data(changeset, current_lang, multilang_enabled) do
    if multilang_enabled && changeset do
      PhoenixKit.Utils.Multilang.get_raw_language_data(
        Ecto.Changeset.get_field(changeset, :data),
        current_lang
      )
    else
      %{}
    end
  end

  @doc """
  Returns true when on the primary language tab (or multilang is disabled).
  """
  def primary_tab?(assigns) do
    !assigns[:multilang_enabled] || assigns[:current_lang] == assigns[:primary_language]
  end

  @doc """
  Preserves primary-language DB field values when on a secondary language tab.

  On secondary tabs, some fields (like title, slug) are absent from form params
  because they're replaced by `lang_*` inputs. This function fills in the missing
  values from the changeset so the DB columns keep their primary-language values.

  `preserve_fields` is a map of `%{"field_name" => :schema_field}`.

  No-ops when multilang is disabled or on the primary tab.
  """
  def preserve_primary_fields(params, changeset, assigns, preserve_fields) do
    do_preserve_primary_fields(params, changeset, assigns, preserve_fields)
  end

  @doc "Returns true when the Languages module is enabled with 2+ languages."
  def multilang_enabled? do
    Code.ensure_loaded?(PhoenixKit.Utils.Multilang) and PhoenixKit.Utils.Multilang.enabled?()
  rescue
    _ -> false
  end

  defp safe_primary_language do
    PhoenixKit.Utils.Multilang.primary_language()
  rescue
    _ -> "en-US"
  end

  defp safe_enabled_languages do
    PhoenixKit.Utils.Multilang.enabled_languages()
  rescue
    _ -> []
  end

  defp safe_build_language_tabs do
    PhoenixKit.Utils.Multilang.build_language_tabs()
  rescue
    _ -> []
  end

  # Bare-key fallback: overrides written outside the form helper
  # (seeds, API imports, `set_translation` callers using plain "name")
  # must not render as an empty input — saving that empty field would
  # wipe the existing translation.
  defp lang_override_value(lang_data, data_key, field_name) do
    case Map.get(lang_data, data_key) do
      nil -> Map.get(lang_data, field_name)
      value -> value
    end
  end

  # Safely reads a field from a changeset, returning "" on any error.
  defp safe_get_field(%Ecto.Changeset{} = changeset, field) when is_atom(field) do
    Ecto.Changeset.get_field(changeset, field) || ""
  rescue
    _ -> ""
  end

  defp safe_get_field(_changeset, _field), do: ""

  # Safely reads the :data field from a changeset, returning %{} on any error.
  defp safe_get_changeset_data(%Ecto.Changeset{} = changeset) do
    Ecto.Changeset.get_field(changeset, :data) || %{}
  rescue
    _ -> %{}
  end

  defp safe_get_changeset_data(_), do: %{}

  # ── Private helpers ────────────────────────────────────────────

  defp extract_translatable_data(params, fields, current_lang, primary) do
    Enum.reduce(fields, %{}, fn field, acc ->
      value =
        if current_lang == primary,
          do: params[field],
          else: params["lang_#{field}"]

      if is_binary(value), do: Map.put(acc, "_#{field}", value), else: acc
    end)
  end

  defp do_merge_multilang_data(changeset, lang_code, validated_data, assigns) do
    existing_data = safe_get_changeset_data(changeset)

    cond do
      assigns[:multilang_enabled] == true ->
        PhoenixKit.Utils.Multilang.put_language_data(existing_data, lang_code, validated_data)

      PhoenixKit.Utils.Multilang.multilang_data?(existing_data) ->
        PhoenixKit.Utils.Multilang.put_language_data(existing_data, lang_code, validated_data)

      true ->
        validated_data
    end
  end

  defp do_preserve_primary_fields(params, _changeset, assigns, preserve_fields)
       when map_size(preserve_fields) == 0 or
              not is_map_key(assigns, :multilang_enabled) do
    params
  end

  defp do_preserve_primary_fields(params, changeset, assigns, preserve_fields) do
    if assigns[:multilang_enabled] && assigns[:current_lang] != assigns[:primary_language] do
      Enum.reduce(preserve_fields, params, fn {str_key, atom_key}, acc ->
        preserve_field_value(acc, changeset, str_key, atom_key)
      end)
    else
      params
    end
  end

  defp preserve_field_value(params, _changeset, _str_key, nil), do: params

  defp preserve_field_value(params, changeset, str_key, atom_key) do
    if Map.has_key?(params, str_key) do
      params
    else
      case Ecto.Changeset.get_field(changeset, atom_key) do
        nil -> params
        value -> Map.put(params, str_key, value)
      end
    end
  end

  # ═══════════════════════════════════════════════════════════════════
  # Components
  # ═══════════════════════════════════════════════════════════════════

  @doc """
  Renders the language tab bar with compact/full mode.

  Shows a header with language icon, an info alert explaining the translation
  workflow, and the shared `<.language_switcher>` in `:tabs` variant with flags,
  names, and primary star indicator.

  Display mode:
  - `compact: nil` (default) — auto: full names when ≤ 5 languages, short codes when more
  - `compact: true` — always short codes
  - `compact: false` — always full names

  Delegates to `PhoenixKitWeb.Components.LanguageSwitcher.language_switcher/1`
  for the tab bar rendering.

  ## Attributes

    * `multilang_enabled` — boolean, whether multilang is active
    * `language_tabs` — list of tab maps from `PhoenixKit.Utils.Multilang.build_language_tabs/0`
    * `current_lang` — the currently selected language code
    * `compact` — force compact mode (short codes). Default: nil (auto)
    * `show_header` — show the "Content Language" header row (title +
      info tooltip + "Primary: …" label). Default: **false** — removed as
      a deliberate product call (2026-08-15): the tab strip itself says
      "languages", and the primary language is already marked by the star
      on its tab, so the row was redundant chrome that pushed the actual
      fields down. The attr remains as an opt-in escape hatch.
    * `show_info` — show an info tooltip next to the header. Default:
      false (it anchors inside the header, so it follows the same call);
      requires `show_header: true` to have an anchor element.
  """
  attr :multilang_enabled, :boolean, required: true
  attr :language_tabs, :list, required: true
  attr :current_lang, :string, required: true
  attr :compact, :boolean, default: nil
  attr :show_header, :boolean, default: false
  attr :show_info, :boolean, default: false
  attr :class, :string, default: "card-body pb-0"

  def multilang_tabs(assigns) do
    display =
      cond do
        assigns.compact == true -> :compact
        assigns.compact == false -> :full
        true -> :auto
      end

    assigns = assign(assigns, :display, display)

    ~H"""
    <div :if={@multilang_enabled && match?([_, _ | _], @language_tabs)} class={@class}>
      <div :if={@show_header} class="flex items-center justify-between mb-3">
        <div class="flex items-center gap-2">
          <.icon name="hero-language" class="w-5 h-5 text-primary" />
          <h2 class="card-title text-lg m-0">{gettext("Content Language")}</h2>
          <%!-- Info tooltip replaces the standalone alert block that
               used to live below the header. Boss feedback was that
               the wall of text was too prominent for what is mostly
               static explanation; hide it behind an info icon that
               reveals on hover/focus. The `[--tooltip-color:…]`
               override is needed because daisyUI's default tooltip
               background can be too dark for small body text on some
               themes — explicit base-300 keeps contrast readable. --%>
          <span
            :if={@show_info}
            class="tooltip tooltip-right cursor-help text-base-content/60 hover:text-base-content [--tooltip-max-width:24rem]"
            data-tip={
              gettext(
                "Use the language tabs below to translate this record's content. The primary language (marked with a star) is required. Other languages are optional — any empty fields will fall back to the primary language value."
              )
            }
          >
            <.icon name="hero-information-circle" class="w-4 h-4" />
          </span>
        </div>
        <% primary_tab = Enum.find(@language_tabs, fn t -> t.is_primary end) %>
        <div :if={primary_tab} class="flex items-center gap-1.5 text-xs text-base-content/60">
          <.icon name="hero-star-solid" class="w-3.5 h-3.5 text-primary" />
          <span>{gettext("Primary: %{lang}", lang: primary_tab.name)}</span>
        </div>
      </div>

      <div class="mb-4">
        <.language_switcher
          languages={@language_tabs}
          current_language={@current_lang}
          on_click_js={&switch_lang_js(&1, @current_lang)}
          display={@display}
          auto_threshold={5}
          show_flags={true}
          show_primary={true}
          primary_divider={true}
          variant={:tabs}
          size={:sm}
          class="w-full"
        />
      </div>
    </div>
    """
  end

  @doc """
  Renders skeleton loading placeholders and a content wrapper for translatable fields.

  The skeleton is shown instantly on tab switch via JS, then hidden when LiveView
  re-renders with the new language data.

  Wrap your translatable form fields inside this component's inner block.

  ## Customizing skeletons

  Use the `:skeleton` slot to provide custom skeleton markup that matches
  your form layout. If omitted, a default two-field skeleton is rendered.

  ## Attributes

    * `multilang_enabled` — boolean
    * `current_lang` — current language code (used in element IDs for morphdom)
    * `skeleton_class` — CSS class for the skeleton container. Default: `"card-body pt-4"`
    * `fields_class` — CSS class for the fields container. Default: nil

  ## Example — default skeleton (card context)

      <.multilang_fields_wrapper multilang_enabled={@multilang_enabled} current_lang={@current_lang}>
        <%!-- translatable fields here --%>
      </.multilang_fields_wrapper>

  ## Example — custom skeleton and classes (non-card context)

      <.multilang_fields_wrapper
        multilang_enabled={@multilang_enabled}
        current_lang={@current_lang}
        skeleton_class="space-y-6"
        fields_class="space-y-6"
      >
        <:skeleton>
          <div class="grid grid-cols-2 gap-6">
            <div class="skeleton h-12 w-full"></div>
            <div class="skeleton h-12 w-full"></div>
          </div>
        </:skeleton>
        <%!-- translatable fields here --%>
      </.multilang_fields_wrapper>
  """
  attr :multilang_enabled, :boolean, required: true
  attr :current_lang, :string, required: true

  attr :switching_lang, :boolean,
    default: false,
    doc:
      "accepted for backwards compatibility but no longer used — skeleton/fields visibility is client-side via `switch_lang_js/2`."

  attr :skeleton_class, :string, default: "card-body pt-4"
  attr :fields_class, :string, default: nil
  slot :skeleton
  slot :inner_block, required: true

  def multilang_fields_wrapper(assigns) do
    ~H"""
    <%!-- Skeleton placeholders (shown instantly on tab click via JS).
         IDs include current_lang so that when the debounce fires and
         current_lang changes, morphdom treats these as new elements
         and replaces them — which resets the skeleton back to `hidden`
         and inserts the fields with new-language content. --%>
    <div
      :if={@multilang_enabled}
      id={"translatable-skeletons-#{@current_lang}"}
      data-translatable="skeletons"
      class={["hidden", @skeleton_class]}
      aria-busy="true"
      aria-label={gettext("Loading language content")}
    >
      <%= if @skeleton != [] do %>
        {render_slot(@skeleton)}
      <% else %>
        <div class="space-y-4">
          <%!-- Default skeleton uses `bg-base-content/15 animate-pulse`
               instead of daisyUI's `.skeleton` because the latter's
               ~8%-opacity base-content grey is nearly invisible on a
               `bg-base-100` card — multiple consumers reported the
               loading state looking like a "blank page". Theme-safe
               via base-content opacity (dark-on-light AND
               light-on-dark themes both render visibly). --%>
          <div class="space-y-2">
            <div class="bg-base-content/15 rounded h-4 w-24 animate-pulse"></div>
            <div class="bg-base-content/15 rounded h-12 w-full animate-pulse"></div>
          </div>
          <div class="space-y-2">
            <div class="bg-base-content/15 rounded h-4 w-32 animate-pulse"></div>
            <div class="bg-base-content/15 rounded h-24 w-full animate-pulse"></div>
          </div>
        </div>
      <% end %>
    </div>

    <div
      id={if @multilang_enabled, do: "translatable-fields-#{@current_lang}", else: "form-fields"}
      data-translatable="fields"
      class={@fields_class}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  @doc """
  Renders a translatable text input or textarea field.

  On the primary language tab, renders a standard input reading from the changeset.
  On secondary language tabs, renders with a language-specific name and uses the
  primary language value as placeholder text.

  Works both inside a `<.multilang_fields_wrapper>` (whole-form translation) and
  standalone (single-field translation).

  ## Translation models

  Supports two naming patterns for secondary language inputs:

  **Default (JSONB data column)** — used by entity data records:
  - Secondary name: `form_prefix[lang_field_name]`
  - Lang data key: `"_field_name"`

  **Settings translations** — used by entity definitions and other models
  that store translations in a `settings["translations"]` map. Set
  `secondary_name` and `lang_data_key` to override the defaults:

      <.translatable_field
        field_name="display_name"
        form_prefix="entities"
        secondary_name={"entities[translations][\#{@current_lang}][display_name]"}
        lang_data_key="display_name"
        ...
      />

  ## Attributes

    * `field_name` — the DB column name (e.g., "name")
    * `form_prefix` — the form name prefix (e.g., "catalogue")
    * `changeset` — the Ecto changeset
    * `schema_field` — the schema field atom (e.g., `:name`)
    * `multilang_enabled` — boolean
    * `current_lang` — current language code
    * `primary_language` — primary language code
    * `lang_data` — raw language data map for the current language
    * `label` — field label text
    * `placeholder` — placeholder for primary language (optional)
    * `type` — "input" or "textarea". Default: "input"
    * `rows` — textarea rows (only for type="textarea"). Default: 3
    * `required` — marks the primary language field as required. Default: false
    * `disabled` — disables the field. Default: false
    * `class` — additional CSS class(es) for the input element. Default: nil
    * `pattern` — HTML pattern attribute for input validation. Default: nil
    * `title` — HTML title attribute (for pattern validation message). Default: nil
    * `hint` — hint text shown below the field. Default: nil
    * `secondary_hint` — hint text shown only on secondary language tabs. Default: nil
    * `secondary_name` — override the secondary tab input name. Default: `"form_prefix[lang_field_name]"`
    * `lang_data_key` — key to look up in `lang_data` for secondary value.
      Default: `"_field_name"`. Set to `"field_name"` for settings translations.
  """
  attr :field_name, :string, required: true
  attr :form_prefix, :string, required: true
  attr :changeset, :any, required: true
  attr :schema_field, :atom, required: true
  attr :multilang_enabled, :boolean, required: true
  attr :current_lang, :string, required: true
  attr :primary_language, :string, required: true
  attr :lang_data, :map, required: true
  attr :label, :string, required: true
  attr :placeholder, :string, default: nil
  attr :type, :string, default: "input"
  attr :rows, :integer, default: 3
  attr :required, :boolean, default: false
  attr :disabled, :boolean, default: false
  attr :class, :string, default: nil
  attr :pattern, :string, default: nil
  attr :title, :string, default: nil

  attr :debounce, :string,
    default: "300",
    doc: """
    Value for `phx-debounce` on the field's input. Defaults to `"300"`, the
    delay every caller had baked in before this was configurable. Pass `nil`
    to omit the attribute entirely — do that when the server DERIVES
    something from this field as you type (a slug from a title, say), where
    waiting for a pause makes the derived value feel laggy.
    """

  attr :hint, :string, default: nil
  attr :secondary_hint, :string, default: nil

  attr :rest, :global,
    doc: """
    Anything else lands on the input itself — `phx-hook`, `data-*`, `aria-*`.
    Without this the component silently swallowed such attributes: they
    reached its assigns and were never rendered, so a caller wiring a JS
    hook to a translated field got no hook and no warning.

    Don't pass `id` here — the input's id is derived internally from
    `field_name`/`form_prefix`/`current_lang`, and `id` is a global HTML
    attribute the compiler won't stop you from spreading; doing so renders
    two conflicting `id` attributes on the same tag.
    """

  attr :mentions, :boolean,
    default: false,
    doc:
      "offer `@` people and `#` records while typing (textarea only). Needs " <>
        "`use PhoenixKit.Mentions.Live` on the LiveView"

  attr :secondary_name, :string, default: nil
  attr :lang_data_key, :string, default: nil
  slot :label_extra

  def translatable_field(assigns) do
    # Coerce lang_data to map if nil/invalid
    assigns =
      if is_map(assigns.lang_data), do: assigns, else: assign(assigns, :lang_data, %{})

    is_primary = !assigns.multilang_enabled || assigns.current_lang == assigns.primary_language

    # Opt-in AND site-enabled AND a textarea: the typeahead has nowhere to
    # go in a single-line input, and a hint promising a feature that is
    # switched off is worse than no hint.
    assigns =
      assign(
        assigns,
        :mentions_on,
        assigns.mentions and assigns.type == "textarea" and PhoenixKit.Mentions.enabled?()
      )

    # Resolve the lang_data lookup key: custom or default "_field_name"
    data_key = assigns.lang_data_key || "_#{assigns.field_name}"

    # Resolve the secondary input name: custom or default "prefix[lang_field]"
    sec_name =
      assigns.secondary_name || "#{assigns.form_prefix}[lang_#{assigns.field_name}]"

    # Extract changeset errors for this field (primary tab only)
    errors = field_errors(assigns.changeset, assigns.schema_field, is_primary)

    assigns =
      assigns
      |> assign(:is_primary, is_primary)
      |> assign(:errors, errors)
      |> assign_new(:primary_value, fn ->
        safe_get_field(assigns.changeset, assigns.schema_field)
      end)
      |> assign_new(:lang_value, fn ->
        lang_override_value(assigns.lang_data, data_key, assigns.field_name)
      end)
      |> assign(:secondary_input_name, sec_name)
      |> assign_new(:input_id, fn ->
        if is_primary do
          "#{assigns.form_prefix}_#{assigns.field_name}"
        else
          "#{assigns.form_prefix}_#{assigns.field_name}_#{assigns.current_lang}"
        end
      end)
      |> assign_new(:input_class, fn ->
        # `w-full` for parity with the regular `<.input>` core component
        # — daisyUI 5's `.input` / `.textarea` classes don't include
        # full-width by default, so without this the field shrinks to
        # the input's intrinsic content width.
        base =
          if assigns.type == "textarea",
            do: "textarea w-full",
            else: "input w-full"

        base = if assigns.class, do: "#{base} #{assigns.class}", else: base
        if errors != [], do: "#{base} input-error", else: base
      end)

    ~H"""
    <%!-- `flex flex-col` because daisyUI 5's `.label` is `inline-flex`
         and `.input`/`.textarea` are `inline-block` — without forcing
         column direction here they sit on the same row. The `gap-1`
         puts a small breathing room between label and field that
         matches the `<.input>` core component's `mb-2`. --%>
    <div
      class="flex flex-col gap-1"
      phx-feedback-for={if @is_primary, do: "#{@form_prefix}[#{@field_name}]"}
    >
      <%!-- Plain font-semibold like <.input>'s label. The 0.75rem these
           labels used to render at came from THIS wrapper's own `fieldset`
           class, not from `fieldset-legend` (which sets no font-size at
           all) — so the size is fixed by dropping `fieldset` above, and the
           span is then byte-identical to Input's. Its grid/gap were already
           overridden by `flex`/`gap-1`; only 0.25rem of padding-block goes
           with it. --%>
      <label for={@input_id} class="label">
        <span class="font-semibold">
          {@label}
          <%= if @required && @is_primary do %>
            *
          <% end %>
        </span>
        {render_slot(@label_extra)}
      </label>
      <%= if @type == "textarea" do %>
        <%= if @is_primary do %>
          <textarea
            name={"#{@form_prefix}[#{@field_name}]"}
            id={@input_id}
            class={@input_class}
            rows={@rows}
            phx-debounce={@debounce}
            phx-hook={@mentions_on && "MentionInput"}
            required={@required}
            disabled={@disabled}
            {@rest}
          >{@primary_value}</textarea>
        <% else %>
          <textarea
            name={@secondary_input_name}
            id={@input_id}
            class={@input_class}
            rows={@rows}
            placeholder={@primary_value}
            phx-debounce={@debounce}
            phx-hook={@mentions_on && "MentionInput"}
            disabled={@disabled}
            {@rest}
          >{@lang_value || ""}</textarea>
        <% end %>
        <p :if={@mentions_on} class="mt-1 text-xs opacity-50">
          {gettext("Type @ to mention someone, # to link a record.")}
        </p>
      <% else %>
        <%= if @is_primary do %>
          <input
            type="text"
            name={"#{@form_prefix}[#{@field_name}]"}
            id={@input_id}
            value={@primary_value}
            class={@input_class}
            placeholder={@placeholder}
            phx-debounce={@debounce}
            required={@required}
            disabled={@disabled}
            pattern={@pattern}
            title={@title}
            {@rest}
          />
        <% else %>
          <input
            type="text"
            name={@secondary_input_name}
            id={@input_id}
            value={@lang_value}
            placeholder={@primary_value}
            class={@input_class}
            phx-debounce={@debounce}
            disabled={@disabled}
            pattern={@pattern}
            title={@title}
            {@rest}
          />
        <% end %>
      <% end %>
      <.error :for={msg <- @errors}>{msg}</.error>
      <label :if={@hint && @is_primary && @errors == []} class="label">
        <span class="fieldset-label">{@hint}</span>
      </label>
      <label :if={@secondary_hint && !@is_primary} class="label">
        <span class="fieldset-label">{@secondary_hint}</span>
      </label>
    </div>
    """
  end

  # Extracts translated error messages for a field from the changeset.
  # Only returns errors on the primary tab — secondary tabs don't validate DB columns.
  defp field_errors(%Ecto.Changeset{action: action, errors: errors}, field, true = _is_primary)
       when not is_nil(action) do
    errors
    |> Keyword.get_values(field)
    |> Enum.map(&translate_error/1)
  end

  defp field_errors(_changeset, _field, _is_primary), do: []

  # ── JS helper ──────────────────────────────────────────────────

  @doc """
  Returns a `Phoenix.LiveView.JS` command that switches languages.

  Toggles skeleton/fields visibility **client-side, instantly** (hides
  `[data-translatable=fields]`, reveals `[data-translatable=skeletons]`)
  and pushes `"switch_language"` to the server. The server side holds
  the class state untouched — `handle_switch_language/2` just schedules
  a 150 ms debounced timer that eventually updates `:current_lang`,
  which changes the wrapper div ids and makes morphdom replace both
  divs with their new-language versions (skeleton back to `hidden`,
  fields visible with new content). Nothing on the server renders
  `hidden` on the fields or removes `hidden` from the skeleton, so the
  JS toggles never fight a server diff.

  Returns a no-op when `lang_code == current_lang` — clicking the
  already-active tab doesn't push and doesn't flash the skeleton.
  """
  def switch_lang_js(lang_code, current_lang) do
    if lang_code == current_lang do
      # Same-lang click during an in-flight debounce: we need to cancel
      # the pending server timer (which would otherwise still fire and
      # flip to the wrong lang) AND revert the skeleton/fields toggles
      # on the client (so the UI doesn't sit stuck in skeleton-visible
      # state after the no-op server render). Pushing with the current
      # lang hits `handle_switch_language/2`'s `cancel_multilang_timer`
      # and reschedules a no-op `current_lang` set.
      JS.push("switch_language", value: %{lang: lang_code})
      |> JS.remove_class("hidden", to: "[data-translatable=fields]")
      |> JS.add_class("hidden", to: "[data-translatable=skeletons]")
    else
      JS.push("switch_language", value: %{lang: lang_code})
      |> JS.add_class("hidden", to: "[data-translatable=fields]")
      |> JS.remove_class("hidden", to: "[data-translatable=skeletons]")
    end
  end
end