# `PhoenixKitEcommerce.Web.Translations`
[🔗](https://github.com/BeamLabEU/phoenix_kit_ecommerce/blob/0.5.11/lib/phoenix_kit_ecommerce/web/translations.ex#L1)

`/admin/shop/translations` — the management page design §4.5 calls the
core of this whole initiative: "интерфейс — ядро задачи." Everything
built in tasks 3–5 (fingerprints/staleness, the category adapter, the
reconciliation sweep) is reachable ONLY from here — design §1's
requirement that no capability exist the interface can't reach.

## Why this page does NOT use `phoenix_kit_ai`'s `AITranslate` components

`FormGlue` / `Embed` / `AITranslate` (design §4.5) are hard-wired to ONE
resource: one `ai_resource_uuid`, one subscription, scalar
`ai_in_flight?` / `ai_progress` / `ai_modal_open`, one
`<dialog id="ai-translation-modal">`. A coverage table with many rows
would collide on every one of those. This page is built directly on
`PhoenixKitAI.Translations.enqueue/1` / `enqueue_all_missing/2` — both
stateless, both taking identifiers as plain arguments — plus this
package's own `AITranslatable` / `CategoryAITranslatable` adapters.

## Data model: rows and columns

A row is a resource (product or category, per `@type_filter` — the two
types are never mixed in one table because their field vocabularies
differ). A column is a target language from
`TranslationSweepSettings.languages/0`. A cell holds that resource's
FOLDED state for that language (design §4.1's `missing > stale >
unknown > fresh`), computed from `TranslationFingerprint.field_state/3`
over every translatable field.

States are computed in this process, not via
`TranslationFingerprint.select_candidates/2`'s hash-in-the-database
query — that query deliberately returns ONLY `missing`/`stale`
candidates (design §4.3: "наружу приезжают только uuid и список
языков"), which is exactly wrong for a table that must also show
`fresh` and `unknown`. At this catalog's measured size (665 products, 7
categories — design §2) loading the filtered set and folding states in
Elixir is milliseconds, matching the design doc's own full-catalog hash
measurements; this is an admin page, not a hot path.

## Bulk verbs and the field/lang scope

Design §4.5: field/lang axes are smuggled into the `data-bulk-action`
event NAME (`"request_translate:de-DE:seo_title"`), the same technique
`PhoenixKitEcommerce.Web.ShopifySync` uses for its per-section actions —
`BulkSelectScope`'s `data-bulk-action` has no way to attach an extra
`phx-value-*`. `"all"` in either segment means "every configured target
language" / "every field of this resource type" — see `bulk_event/3`
and `parse_scope/3`.

Three verbs (design §4.4, §4.5):

  * **translate** — `missing ∪ stale` only, computed fresh per resource
    at confirm time (never trusts the request-time snapshot — see
    `PhoenixKitEcommerce.Web.ShopifySync`'s moduledoc on why). The
    field scope narrows nothing here (design §4.4: "На состав задания
    он не влияет") — only the already-filtered row selection does that.
  * **retranslate** ("перевести заново") — resets the fingerprints for
    the selected langs × fields (`reset_reference/3`) and THEN enqueues
    for exactly that lang list, unconditionally: a freshly-reset field
    reads `:unknown`, which the ordinary missing∪stale computation
    would never pick up on its own.
  * **stamp** ("проштамповать") — `stamp_reference/4`, no model call.

## Catalogue-wide stamp / reset (Fix D)

Every verb above reads its `uuids` off the bulk-select click payload,
which `BulkSelectScope` can only ever populate from the rows currently
in the DOM — one `@per_page` page (see
`PhoenixKitWeb.Components.Core.BulkSelect`'s own moduledoc: "purely
client-side"). Design §1's requirement — no capability the interface
can't reach — is violated by that alone for the §4.1 rollout: stamping
634 existing resources as reference 25 rows at a time is not a
capability an admin will ever actually use; the only route that could
do it in one shot was the CLI task, which no admin sees.

`request_stamp_all` / `request_reset_all` fix that by NOT depending on
any client-side selection at all. `matching_rows/1` re-queries every
resource the CURRENT filter (type/category/lang/state/field/search)
matches, across every page — the same function `load_data/1` uses to
build the table, so the confirmation modal's resource count can never
drift from what the table itself would show if it had no pagination.
They go through the identical request → confirm two-phase flow as
every other verb here (`open_pending_all/2` -> `@pending` ->
`<.confirm_modal>` -> `do_confirm/2`).

Deliberately just these two verbs, not a catalogue-wide "translate":
`stamp_reference/4` and `reset_reference/3` are metadata-only writes
(no model call, no Oban job) — cheap and safe to run over the whole
filtered set. A catalogue-wide TRANSLATE would enqueue a real
`TranslateWorker` job per candidate language, and design §13 is
explicit that a run of that size (1330 jobs at full catalogue scope,
design §8) happens only by the owner's own separate decision, never as
a button on this page.

## What is deliberately NOT here

Per-field JOB scoping (design §12.2 rejected `resource_scope` for
exactly this reason) — the field axis only narrows what gets WRITTEN
(`put_translation/4`'s write-narrowing) and, for retranslate/stamp,
what gets touched in `metadata`. A "translate" job always asks the
model for every non-empty field. Also not here: a catalogue-wide
"translate" button — see the section above.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
