+
+ {localeStats.map((locale) => {
+ const heightPercent = (locale.total / maxTotal) * 100
+ const translatedPercent =
+ locale.total > 0
+ ? (locale.translated / locale.total) * 100
+ : 0
+
+ return (
+
+
+ {locale.name}
+
+
+
+
+
+ {t("translations.completion.translated")}
+
+
+
+ {locale.translated}
+
+
+
+
+
+
+ {t("translations.completion.toTranslate")}
+
+
+
+ {locale.toTranslate}
+
+
+
+ }
+ >
+
+
setHoveredLocale(locale.code)}
+ onMouseLeave={() => setHoveredLocale(null)}
+ >
+ {translatedPercent === 0 ? (
+
+ ) : (
+ <>
+
0 ? "2px" : "0",
+ }}
+ />
+ {translatedPercent > 0 && (
+
0 ? "2px" : "0",
+ }}
+ />
+ )}
+ >
+ )}
+
+
+
+ )
+ })}
+
+ {localeStatsCount < 9 && (
+
+ {localeStats.map((locale) => (
+
+
+ {localeStatsCount < 6 ? locale.name : locale.code}
+
+
+ ))}
+
+ )}
+ {localeStatsCount > 9 && (
+
+ {t("translations.completion.footer")}
+
+ )}
+
+ >
+ )}
+
+ )
+}
diff --git a/packages/admin/dashboard/src/routes/translations/translation-list/index.tsx b/packages/admin/dashboard/src/routes/translations/translation-list/index.tsx
new file mode 100644
index 0000000000..02d66999cf
--- /dev/null
+++ b/packages/admin/dashboard/src/routes/translations/translation-list/index.tsx
@@ -0,0 +1 @@
+export { TranslationList as Component } from "./translation-list"
diff --git a/packages/admin/dashboard/src/routes/translations/translation-list/translation-list.tsx b/packages/admin/dashboard/src/routes/translations/translation-list/translation-list.tsx
new file mode 100644
index 0000000000..fd10aff7cc
--- /dev/null
+++ b/packages/admin/dashboard/src/routes/translations/translation-list/translation-list.tsx
@@ -0,0 +1,138 @@
+import { Container, Heading, Text } from "@medusajs/ui"
+import { TwoColumnPage } from "../../../components/layout/pages"
+import { useTranslation } from "react-i18next"
+import {
+ useStore,
+ useTranslationSettings,
+ useTranslationStatistics,
+} from "../../../hooks/api"
+import { ActiveLocalesSection } from "./components/active-locales-section/active-locales-section"
+import { TranslationListSection } from "./components/translation-list-section/translation-list-section"
+import { TranslationsCompletionSection } from "./components/translations-completion-section/translations-completion-section"
+import { TwoColumnPageSkeleton } from "../../../components/common/skeleton"
+import { useMemo } from "react"
+
+export type TranslatableEntity = {
+ label: string
+ reference: string
+ translatableFields: string[]
+ translatedCount?: number
+ totalCount?: number
+}
+
+export const TranslationList = () => {
+ const { t } = useTranslation()
+
+ const { store, isPending, isError, error } = useStore()
+ const {
+ translatable_fields,
+ isPending: isTranslationSettingsPending,
+ isError: isTranslationSettingsError,
+ error: translationSettingsError,
+ } = useTranslationSettings()
+ const {
+ statistics,
+ isPending: isTranslationStatisticsPending,
+ isError: isTranslationStatisticsError,
+ error: translationStatisticsError,
+ } = useTranslationStatistics(
+ {
+ locales:
+ store?.supported_locales?.map(
+ (suportedLocale) => suportedLocale.locale_code
+ ) ?? [],
+ entity_types: Object.keys(translatable_fields ?? {}),
+ },
+ {
+ enabled:
+ !!translatable_fields && !!store && store.supported_locales?.length > 0,
+ }
+ )
+
+ if (isError || isTranslationSettingsError || isTranslationStatisticsError) {
+ throw error || translationSettingsError || translationStatisticsError
+ }
+
+ const hasLocales = (store?.supported_locales ?? []).length > 0
+
+ const translatableEntities: TranslatableEntity[] = useMemo(() => {
+ if (!translatable_fields) {
+ return []
+ }
+
+ return Object.entries(translatable_fields)
+ .filter(
+ ([entity]) =>
+ !["product_option", "product_option_value"].includes(entity)
+ )
+ .map(([entity, fields]) => {
+ const entityStatistics = statistics?.[entity] ?? {
+ translated: 0,
+ expected: 0,
+ }
+
+ return {
+ label: entity
+ .split("_")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" "),
+ reference: entity,
+ translatableFields: fields,
+ translatedCount: entityStatistics.translated,
+ totalCount: entityStatistics.expected,
+ }
+ })
+ }, [translatable_fields, statistics])
+
+ const isReady =
+ !!store &&
+ !isPending &&
+ !isTranslationSettingsPending &&
+ !!translatable_fields &&
+ ((!!statistics && !isTranslationStatisticsPending) || !hasLocales)
+
+ if (!isReady) {
+ return
+ }
+
+ return (
+
+
+
+ Manage {t("translations.domain")}
+
+ {t("translations.subtitle")}
+
+
+
+
+
+ suportedLocale.locale
+ ) ?? []
+ }
+ >
+ supportedLocale.locale
+ ) ?? []
+ }
+ />
+
+
+ )
+}
diff --git a/packages/admin/dashboard/src/routes/translations/translations-edit/components/translations-edit-form/index.ts b/packages/admin/dashboard/src/routes/translations/translations-edit/components/translations-edit-form/index.ts
new file mode 100644
index 0000000000..b32ea21b63
--- /dev/null
+++ b/packages/admin/dashboard/src/routes/translations/translations-edit/components/translations-edit-form/index.ts
@@ -0,0 +1 @@
+export * from "./translations-edit-form"
diff --git a/packages/admin/dashboard/src/routes/translations/translations-edit/components/translations-edit-form/translations-edit-form.tsx b/packages/admin/dashboard/src/routes/translations/translations-edit/components/translations-edit-form/translations-edit-form.tsx
new file mode 100644
index 0000000000..17683f7b92
--- /dev/null
+++ b/packages/admin/dashboard/src/routes/translations/translations-edit/components/translations-edit-form/translations-edit-form.tsx
@@ -0,0 +1,747 @@
+import { zodResolver } from "@hookform/resolvers/zod"
+import { AdminStoreLocale, HttpTypes } from "@medusajs/types"
+import { Button, Prompt, Select, toast, Text } from "@medusajs/ui"
+import { ColumnDef } from "@tanstack/react-table"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useForm } from "react-hook-form"
+import { useTranslation } from "react-i18next"
+import { z } from "zod"
+
+import {
+ createDataGridHelper,
+ DataGrid,
+} from "../../../../../components/data-grid"
+import {
+ RouteFocusModal,
+ useRouteModal,
+} from "../../../../../components/modals"
+import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
+import { useBatchTranslations } from "../../../../../hooks/api/translations"
+
+const EntityTranslationsSchema = z.object({
+ id: z.string().nullish(),
+ fields: z.record(z.string().optional()),
+})
+export type EntityTranslationsSchema = z.infer
+
+export const TranslationsFormSchema = z.object({
+ entities: z.record(EntityTranslationsSchema),
+})
+export type TranslationsFormSchema = z.infer
+
+export type TranslationRow = EntityRow | FieldRow
+
+export type EntityRow = {
+ _type: "entity"
+ reference_id: string
+ subRows: FieldRow[]
+}
+
+export type FieldRow = {
+ _type: "field"
+ reference_id: string
+ field_name: string
+}
+
+export function isEntityRow(row: TranslationRow): row is EntityRow {
+ return row._type === "entity"
+}
+
+export function isFieldRow(row: TranslationRow): row is FieldRow {
+ return row._type === "field"
+}
+
+type LocaleSnapshot = {
+ localeCode: string
+ entities: Record
+}
+
+function buildLocaleSnapshot(
+ translations: HttpTypes.AdminTranslation[],
+ references: { id: string; [key: string]: string }[],
+ localeCode: string,
+ translatableFields: string[]
+): LocaleSnapshot {
+ const referenceTranslations = new Map()
+ for (const t of translations) {
+ if (t.locale_code === localeCode) {
+ referenceTranslations.set(t.reference_id, t)
+ }
+ }
+
+ const entities: Record = {}
+ for (const ref of references) {
+ const existing = referenceTranslations.get(ref.id)
+ const fields: Record = {}
+
+ for (const fieldName of translatableFields) {
+ fields[fieldName] = (existing?.translations?.[fieldName] as string) ?? ""
+ }
+
+ entities[ref.id] = {
+ id: existing?.id ?? null,
+ fields,
+ }
+ }
+
+ return { localeCode, entities }
+}
+
+function extendSnapshot(
+ snapshot: LocaleSnapshot,
+ translations: HttpTypes.AdminTranslation[],
+ newReferences: { id: string; [key: string]: string }[],
+ translatableFields: string[]
+): LocaleSnapshot {
+ const referenceTranslations = new Map()
+ for (const t of translations) {
+ if (t.locale_code === snapshot.localeCode) {
+ referenceTranslations.set(t.reference_id, t)
+ }
+ }
+
+ const extendedEntities = { ...snapshot.entities }
+
+ for (const ref of newReferences) {
+ if (!extendedEntities[ref.id]) {
+ const existing = referenceTranslations.get(ref.id)
+ const fields: Record = {}
+
+ for (const fieldName of translatableFields) {
+ fields[fieldName] =
+ (existing?.translations?.[fieldName] as string) ?? ""
+ }
+
+ extendedEntities[ref.id] = {
+ id: existing?.id ?? null,
+ fields,
+ }
+ }
+ }
+
+ return { ...snapshot, entities: extendedEntities }
+}
+
+function snapshotToFormValues(
+ snapshot: LocaleSnapshot
+): TranslationsFormSchema {
+ return { entities: snapshot.entities }
+}
+
+type ChangeDetectionResult = {
+ hasChanges: boolean
+ payload: Required
+}
+
+function computeChanges(
+ currentState: TranslationsFormSchema,
+ snapshot: LocaleSnapshot,
+ entityType: string,
+ localeCode: string
+): ChangeDetectionResult {
+ const payload: Required = {
+ create: [],
+ update: [],
+ delete: [],
+ }
+
+ for (const [entityId, entityData] of Object.entries(currentState.entities)) {
+ const baseline = snapshot.entities[entityId]
+ if (!baseline) {
+ continue
+ }
+
+ const hasContent = Object.values(entityData.fields).some(
+ (v) => v !== undefined && v.trim() !== ""
+ )
+ const hadContent = Object.values(baseline.fields).some(
+ (v) => v !== undefined && v.trim() !== ""
+ )
+ const hasChanged =
+ JSON.stringify(entityData.fields) !== JSON.stringify(baseline.fields)
+
+ if (!entityData.id && hasContent) {
+ payload.create.push({
+ reference_id: entityId,
+ reference: entityType,
+ locale_code: localeCode,
+ translations: entityData.fields,
+ })
+ } else if (entityData.id && hasContent && hasChanged) {
+ payload.update.push({
+ id: entityData.id,
+ translations: entityData.fields,
+ })
+ } else if (entityData.id && !hasContent && hadContent) {
+ payload.delete.push(entityData.id)
+ }
+ }
+
+ const hasChanges =
+ payload.create.length > 0 ||
+ payload.update.length > 0 ||
+ payload.delete.length > 0
+
+ return { hasChanges, payload }
+}
+
+const columnHelper = createDataGridHelper<
+ TranslationRow,
+ TranslationsFormSchema
+>()
+
+const FIELD_COLUMN_WIDTH = 350
+
+function buildTranslationRows(
+ references: { id: string; [key: string]: string }[],
+ translatableFields: string[]
+): TranslationRow[] {
+ return references.map((reference) => ({
+ _type: "entity" as const,
+ reference_id: reference.id,
+ subRows: translatableFields.map((fieldName) => ({
+ _type: "field" as const,
+ reference_id: reference.id,
+ field_name: fieldName,
+ })),
+ }))
+}
+
+function useTranslationsGridColumns({
+ entities,
+ availableLocales,
+ selectedLocale,
+ dynamicColumnWidth,
+}: {
+ entities: { id: string; [key: string]: string }[]
+ availableLocales: AdminStoreLocale[]
+ selectedLocale: string
+ dynamicColumnWidth: number
+}) {
+ const { t } = useTranslation()
+
+ return useMemo(() => {
+ const selectedLocaleData = availableLocales.find(
+ (l) => l.locale_code === selectedLocale
+ )
+
+ const columns: ColumnDef[] = [
+ columnHelper.column({
+ id: "field",
+ name: "field",
+ size: FIELD_COLUMN_WIDTH,
+ header: undefined,
+ cell: (context) => {
+ const row = context.row.original
+
+ if (isEntityRow(row)) {
+ return
+ }
+
+ return (
+
+
+
+ {t(`fields.${row.field_name}`, {
+ defaultValue: row.field_name,
+ })}
+
+
+
+ )
+ },
+ disableHiding: true,
+ }),
+ columnHelper.column({
+ id: "original",
+ name: "original",
+ size: dynamicColumnWidth,
+ header: () => (
+
+ {t("general.original")}
+
+ ),
+ disableHiding: true,
+ cell: (context) => {
+ const row = context.row.original
+
+ if (isEntityRow(row)) {
+ return
+ }
+
+ const entity = entities.find((e) => e.id === row.reference_id)
+ if (!entity) {
+ return null
+ }
+
+ return (
+
+
+ {entity[row.field_name]}
+
+
+ )
+ },
+ }),
+ ]
+
+ if (selectedLocaleData) {
+ columns.push(
+ columnHelper.column({
+ id: selectedLocaleData.locale_code,
+ name: selectedLocaleData.locale.name,
+ size: dynamicColumnWidth,
+ header: () => (
+
+ {selectedLocaleData.locale.name}
+
+ ),
+ cell: (context) => {
+ const row = context.row.original
+
+ if (isEntityRow(row)) {
+ return
+ }
+
+ return
+ },
+ field: (context) => {
+ const row = context.row.original
+
+ if (isEntityRow(row)) {
+ return null
+ }
+
+ return `entities.${row.reference_id}.fields.${row.field_name}`
+ },
+ type: "multiline-text",
+ })
+ )
+ }
+
+ return columns
+ }, [t, availableLocales, selectedLocale, entities, dynamicColumnWidth])
+}
+
+type TranslationsEditFormProps = {
+ translations: HttpTypes.AdminTranslation[]
+ references: { id: string; [key: string]: string }[]
+ entityType: string
+ availableLocales: AdminStoreLocale[]
+ translatableFields: string[]
+ fetchNextPage: () => void
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ referenceCount: number
+}
+
+export const TranslationsEditForm = ({
+ translations,
+ references,
+ entityType,
+ availableLocales,
+ translatableFields,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ referenceCount,
+}: TranslationsEditFormProps) => {
+ const { t } = useTranslation()
+ const { handleSuccess, setCloseOnEscape } = useRouteModal()
+
+ const containerRef = useRef(null)
+ const [dynamicColumnWidth, setDynamicColumnWidth] = useState(400)
+
+ useEffect(() => {
+ const calculateColumnWidth = () => {
+ if (containerRef.current) {
+ const containerWidth = containerRef.current.offsetWidth
+ const availableWidth = containerWidth - FIELD_COLUMN_WIDTH - 16
+ const columnWidth = Math.max(300, Math.floor(availableWidth / 2))
+ setDynamicColumnWidth(columnWidth)
+ }
+ }
+
+ calculateColumnWidth()
+
+ const resizeObserver = new ResizeObserver(calculateColumnWidth)
+ if (containerRef.current) {
+ resizeObserver.observe(containerRef.current)
+ }
+
+ return () => resizeObserver.disconnect()
+ }, [])
+
+ const [selectedLocale, setSelectedLocale] = useState(
+ availableLocales[0]?.locale_code ?? ""
+ )
+ const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false)
+ const [pendingLocale, setPendingLocale] = useState(null)
+ const [isSwitchingLocale, setIsSwitchingLocale] = useState(false)
+
+ const snapshotRef = useRef(
+ buildLocaleSnapshot(
+ translations,
+ references,
+ selectedLocale,
+ translatableFields
+ )
+ )
+
+ const knownEntityIdsRef = useRef>(
+ new Set(references.map((r) => r.id))
+ )
+
+ const latestPropsRef = useRef({ translations, references })
+ useEffect(() => {
+ latestPropsRef.current = { translations, references }
+ }, [translations, references])
+
+ const form = useForm({
+ resolver: zodResolver(TranslationsFormSchema),
+ defaultValues: snapshotToFormValues(snapshotRef.current),
+ })
+
+ useEffect(() => {
+ const currentIds = new Set(references.map((r) => r.id))
+ const newReferences = references.filter(
+ (r) => !knownEntityIdsRef.current.has(r.id)
+ )
+
+ if (newReferences.length === 0) {
+ return
+ }
+
+ knownEntityIdsRef.current = currentIds
+ snapshotRef.current = extendSnapshot(
+ snapshotRef.current,
+ translations,
+ newReferences,
+ translatableFields
+ )
+
+ const currentValues = form.getValues()
+ const newFormValues: TranslationsFormSchema = {
+ entities: { ...currentValues.entities },
+ }
+
+ for (const ref of newReferences) {
+ if (!newFormValues.entities[ref.id]) {
+ newFormValues.entities[ref.id] = snapshotRef.current.entities[ref.id]
+ }
+ }
+
+ form.reset(newFormValues, {
+ keepDirty: true,
+ keepDirtyValues: true,
+ })
+ }, [references, translations, translatableFields, form])
+
+ const rows = useMemo(
+ () => buildTranslationRows(references, translatableFields),
+ [references, translatableFields]
+ )
+
+ const totalRowCount = useMemo(
+ () => referenceCount * (translatableFields.length + 1),
+ [referenceCount, translatableFields]
+ )
+
+ const selectedLocaleDisplay = useMemo(
+ () =>
+ availableLocales.find((l) => l.locale_code === selectedLocale)?.locale
+ .name,
+ [availableLocales, selectedLocale]
+ )
+
+ const columns = useTranslationsGridColumns({
+ entities: references,
+ availableLocales,
+ selectedLocale,
+ dynamicColumnWidth,
+ })
+
+ const { mutateAsync, isPending, invalidateQueries } =
+ useBatchTranslations(entityType)
+
+ const saveCurrentLocale = useCallback(async () => {
+ const currentValues = form.getValues()
+ const { hasChanges, payload } = computeChanges(
+ currentValues,
+ snapshotRef.current,
+ entityType,
+ selectedLocale
+ )
+
+ if (!hasChanges) {
+ return true
+ }
+
+ try {
+ const BATCH_SIZE = 150
+ const totalItems =
+ payload.create.length + payload.update.length + payload.delete.length
+ const batchCount = Math.ceil(totalItems / BATCH_SIZE)
+
+ for (let i = 0; i < batchCount; i++) {
+ let currentBatchAvailable = BATCH_SIZE
+
+ const currentBatch: HttpTypes.AdminBatchTranslations = {
+ create: [],
+ update: [],
+ delete: [],
+ }
+
+ if (payload.create.length > 0) {
+ currentBatch.create = payload.create.splice(0, currentBatchAvailable)
+ currentBatchAvailable -= currentBatch.create.length
+ }
+ if (payload.update.length > 0) {
+ currentBatch.update = payload.update.splice(0, currentBatchAvailable)
+ currentBatchAvailable -= currentBatch.update.length
+ }
+ if (payload.delete.length > 0) {
+ currentBatch.delete = payload.delete.splice(0, currentBatchAvailable)
+ }
+
+ const response = await mutateAsync(currentBatch, {
+ onError: (error) => {
+ toast.error(error.message)
+ },
+ })
+
+ if (response.created) {
+ for (const created of response.created) {
+ form.setValue(`entities.${created.reference_id}.id`, created.id, {
+ shouldDirty: false,
+ })
+ if (snapshotRef.current.entities[created.reference_id]) {
+ snapshotRef.current.entities[created.reference_id].id = created.id
+ }
+ }
+ }
+ }
+
+ const savedValues = form.getValues()
+ for (const entityId of Object.keys(savedValues.entities)) {
+ if (snapshotRef.current.entities[entityId]) {
+ snapshotRef.current.entities[entityId] = {
+ ...savedValues.entities[entityId],
+ }
+ }
+ }
+
+ form.reset(savedValues)
+
+ return true
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to save translations"
+ )
+ return false
+ }
+ }, [form, entityType, selectedLocale, mutateAsync])
+
+ const switchToLocale = useCallback(
+ async (newLocale: string) => {
+ setIsSwitchingLocale(true)
+
+ try {
+ await invalidateQueries()
+
+ await new Promise((resolve) => requestAnimationFrame(resolve))
+
+ const { translations, references } = latestPropsRef.current
+
+ const newSnapshot = buildLocaleSnapshot(
+ translations,
+ references,
+ newLocale,
+ translatableFields
+ )
+
+ snapshotRef.current = newSnapshot
+ knownEntityIdsRef.current = new Set(references.map((r) => r.id))
+
+ form.reset(snapshotToFormValues(newSnapshot))
+
+ setSelectedLocale(newLocale)
+ } finally {
+ setIsSwitchingLocale(false)
+ }
+ },
+ [translatableFields, form, invalidateQueries]
+ )
+
+ const handleLocaleChange = useCallback(
+ (newLocale: string) => {
+ if (newLocale === selectedLocale) {
+ return
+ }
+
+ const currentValues = form.getValues()
+ const { hasChanges } = computeChanges(
+ currentValues,
+ snapshotRef.current,
+ entityType,
+ selectedLocale
+ )
+
+ if (hasChanges) {
+ setPendingLocale(newLocale)
+ setShowUnsavedPrompt(true)
+ } else {
+ switchToLocale(newLocale)
+ }
+ },
+ [selectedLocale, form, entityType, switchToLocale]
+ )
+
+ const handleSaveAndSwitch = useCallback(async () => {
+ const success = await saveCurrentLocale()
+ if (success && pendingLocale) {
+ toast.success(t("translations.edit.successToast"))
+ await switchToLocale(pendingLocale)
+ }
+ setShowUnsavedPrompt(false)
+ setPendingLocale(null)
+ }, [saveCurrentLocale, pendingLocale, t, switchToLocale])
+
+ const handleCancelSwitch = useCallback(() => {
+ setShowUnsavedPrompt(false)
+ setPendingLocale(null)
+ }, [])
+
+ const handleSave = useCallback(
+ async (closeOnSuccess: boolean = false) => {
+ const success = await saveCurrentLocale()
+ if (success) {
+ toast.success(t("translations.edit.successToast"))
+ if (closeOnSuccess) {
+ handleSuccess()
+ }
+ }
+ },
+ [saveCurrentLocale, t, handleSuccess]
+ )
+
+ const handleClose = useCallback(() => {
+ invalidateQueries()
+ }, [invalidateQueries])
+
+ const isLoading = isPending || isSwitchingLocale
+
+ return (
+
+ handleSave(true)}
+ className="flex h-full flex-col overflow-hidden"
+ >
+
+
+
+ {
+ if (isEntityRow(row)) {
+ return row.subRows
+ }
+ }}
+ state={form}
+ onEditingChange={(editing) => setCloseOnEscape(!editing)}
+ totalRowCount={totalRowCount}
+ onFetchMore={fetchNextPage}
+ isFetchingMore={isFetchingNextPage}
+ hasNextPage={hasNextPage}
+ headerContent={
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("translations.edit.unsavedChanges.title")}
+
+
+ {t("translations.edit.unsavedChanges.description")}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/admin/dashboard/src/routes/translations/translations-edit/index.ts b/packages/admin/dashboard/src/routes/translations/translations-edit/index.ts
new file mode 100644
index 0000000000..ec2c0e1223
--- /dev/null
+++ b/packages/admin/dashboard/src/routes/translations/translations-edit/index.ts
@@ -0,0 +1 @@
+export { TranslationsEdit as Component } from "./translations-edit"
diff --git a/packages/admin/dashboard/src/routes/translations/translations-edit/translations-edit.tsx b/packages/admin/dashboard/src/routes/translations/translations-edit/translations-edit.tsx
new file mode 100644
index 0000000000..93c1f2a48e
--- /dev/null
+++ b/packages/admin/dashboard/src/routes/translations/translations-edit/translations-edit.tsx
@@ -0,0 +1,90 @@
+import { useNavigate, useSearchParams } from "react-router-dom"
+import {
+ useReferenceTranslations,
+ useStore,
+ useTranslationSettings,
+} from "../../../hooks/api"
+import { TranslationsEditForm } from "./components/translations-edit-form"
+import { useEffect } from "react"
+import { RouteFocusModal } from "../../../components/modals"
+import { useFeatureFlag } from "../../../providers/feature-flag-provider"
+import { keepPreviousData } from "@tanstack/react-query"
+
+export const TranslationsEdit = () => {
+ const isTranslationsEnabled = useFeatureFlag("translation")
+ const navigate = useNavigate()
+ const [searchParams] = useSearchParams()
+ const reference = searchParams.get("reference")
+ const referenceIdParam = searchParams.getAll("reference_id")
+
+ useEffect(() => {
+ if (!reference || !isTranslationsEnabled) {
+ navigate(-1)
+ return
+ }
+ }, [reference, navigate, isTranslationsEnabled])
+
+ const {
+ translatable_fields,
+ isPending: isTranslationSettingsPending,
+ isError: isTranslationSettingsError,
+ error: translationSettingsError,
+ } = useTranslationSettings({ entity_type: reference! })
+
+ const {
+ translations,
+ references,
+ fetchNextPage,
+ count,
+ isFetchingNextPage,
+ hasNextPage,
+ isPending,
+ isError,
+ error,
+ } = useReferenceTranslations(
+ reference!,
+ translatable_fields?.[reference!] ?? [],
+ referenceIdParam,
+ {
+ enabled: !!translatable_fields && !!reference,
+ placeholderData: keepPreviousData,
+ }
+ )
+ const {
+ store,
+ isPending: isStorePending,
+ isError: isStoreError,
+ error: storeError,
+ } = useStore()
+
+ const ready =
+ !isPending &&
+ !!translations &&
+ !!translatable_fields &&
+ !isTranslationSettingsPending &&
+ !!references &&
+ !isStorePending &&
+ !!store
+
+ if (isError || isStoreError || isTranslationSettingsError) {
+ throw error || storeError || translationSettingsError
+ }
+
+ return (
+
+ {ready && (
+
+ )}
+
+ )
+}
diff --git a/packages/core/core-flows/src/translation/steps/validate-translations.ts b/packages/core/core-flows/src/translation/steps/validate-translations.ts
index cf3b57aa26..4ae8c644dd 100644
--- a/packages/core/core-flows/src/translation/steps/validate-translations.ts
+++ b/packages/core/core-flows/src/translation/steps/validate-translations.ts
@@ -61,7 +61,7 @@ export const validateTranslationsStep = createStep(
const unsupportedLocales = normalizedInput
.filter((translation) => Boolean(translation.locale_code))
.map((translation) => translation.locale_code)
- .filter((locale) => !enabledLocales.includes(locale ?? ""))
+ .filter((locale) => !enabledLocales.includes(locale))
if (unsupportedLocales.length) {
throw new MedusaError(
diff --git a/packages/core/js-sdk/src/admin/index.ts b/packages/core/js-sdk/src/admin/index.ts
index 68f872fb8e..347c2e1b4f 100644
--- a/packages/core/js-sdk/src/admin/index.ts
+++ b/packages/core/js-sdk/src/admin/index.ts
@@ -46,6 +46,7 @@ import { Views } from "./views"
import { WorkflowExecution } from "./workflow-execution"
import { ShippingOptionType } from "./shipping-option-type"
import { Locale } from "./locale"
+import { Translation } from "./translation"
export class Admin {
/**
@@ -225,6 +226,10 @@ export class Admin {
* @tags tax
*/
public taxProvider: TaxProvider
+ /**
+ * @tags translations
+ */
+ public translation: Translation
/**
* @tags promotion
*/
@@ -268,6 +273,7 @@ export class Admin {
this.claim = new Claim(client)
this.taxRate = new TaxRate(client)
this.taxRegion = new TaxRegion(client)
+ this.translation = new Translation(client)
this.store = new Store(client)
this.productTag = new ProductTag(client)
this.user = new User(client)
diff --git a/packages/core/js-sdk/src/admin/translation.ts b/packages/core/js-sdk/src/admin/translation.ts
new file mode 100644
index 0000000000..a2a797591d
--- /dev/null
+++ b/packages/core/js-sdk/src/admin/translation.ts
@@ -0,0 +1,206 @@
+import { HttpTypes } from "@medusajs/types"
+import { Client } from "../client"
+import { ClientHeaders } from "../types"
+
+export class Translation {
+ /**
+ * @ignore
+ */
+ private client: Client
+
+ /**
+ * @ignore
+ */
+ constructor(client: Client) {
+ this.client = client
+ }
+
+ /**
+ * This method retrieves a paginated list of translations. It sends a request to the
+ * [List Translations](https://docs.medusajs.com/api/admin#translations_gettranslations)
+ * API route.
+ *
+ * @param query - Filters and pagination configurations.
+ * @param headers - Headers to pass in the request.
+ * @returns The paginated list of translations.
+ *
+ * @example
+ * To retrieve the list of translations:
+ *
+ * ```ts
+ * sdk.admin.translation.list()
+ * .then(({ translations, count, limit, offset }) => {
+ * console.log(translations)
+ * })
+ * ```
+ *
+ * To configure the pagination, pass the `limit` and `offset` query parameters.
+ *
+ * For example, to retrieve only 10 items and skip 10 items:
+ *
+ * ```ts
+ * sdk.admin.translation.list({
+ * limit: 10,
+ * offset: 10
+ * })
+ * .then(({ translations, count, limit, offset }) => {
+ * console.log(translations)
+ * })
+ * ```
+ *
+ * Using the `fields` query parameter, you can specify the fields and relations to retrieve
+ * in each translation:
+ *
+ * ```ts
+ * sdk.admin.translation.list({
+ * fields: "id,name"
+ * })
+ * .then(({ translations, count, limit, offset }) => {
+ * console.log(translations)
+ * })
+ * ```
+ *
+ * Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
+ */
+ async list(
+ query?: HttpTypes.AdminTranslationsListParams,
+ headers?: ClientHeaders
+ ) {
+ return await this.client.fetch(
+ `/admin/translations`,
+ {
+ headers,
+ query,
+ }
+ )
+ }
+
+ /**
+ * This method allows bulk operations on translations. It sends a request to the
+ * [Manage Translations](https://docs.medusajs.com/api/admin#translations_posttranslationsbatch)
+ * API route.
+ *
+ * @param payload - The translations to create, update, or delete.
+ * @param headers - Headers to pass in the request.
+ * @returns The translations' details.
+ *
+ * @example
+ * sdk.admin.translation.batch({
+ * create: [
+ * {
+ * reference_id: "prod_123",
+ * reference: "product",
+ * locale_code: "en-US",
+ * translations: { title: "Shirt" }
+ * }
+ * ],
+ * update: [
+ * {
+ * id: "trans_123",
+ * translations: { title: "Pants" }
+ * }
+ * ],
+ * delete: ["trans_321"]
+ * })
+ * .then(({ created, updated, deleted }) => {
+ * console.log(created, updated, deleted)
+ * })
+ * ```
+ */
+ async batch(body: HttpTypes.AdminBatchTranslations, headers?: ClientHeaders) {
+ return await this.client.fetch(
+ `/admin/translations/batch`,
+ {
+ method: "POST",
+ headers,
+ body,
+ }
+ )
+ }
+
+ /**
+ * This method retrieves the settings for the translations for a given entity type or all entity types if no entity type is provided.
+ * It sends a request to the
+ * [Get Translation Settings](https://docs.medusajs.com/api/admin#translations_gettranslationssettings) API route.
+ *
+ * @param query - The query parameters which can optionally include the entity type to get the settings for.
+ * @param headers - Headers to pass in the request.
+ * @returns The translation settings.
+ *
+ * @example
+ * To retrieve the settings for the translations for a given entity type:
+ *
+ * ```ts
+ * sdk.admin.translation.settings({
+ * entity_type: "product"
+ * })
+ * .then(({ translatable_fields }) => {
+ * console.log(translatable_fields)
+ * })
+ * ```
+ *
+ * To retrieve the settings for all entity types:
+ *
+ * ```ts
+ * sdk.admin.translation.settings()
+ * .then(({ translatable_fields }) => {
+ * console.log(translatable_fields)
+ * })
+ * ```
+ */
+ async settings(
+ query?: HttpTypes.AdminTranslationSettingsParams,
+ headers?: ClientHeaders
+ ) {
+ return await this.client.fetch(
+ `/admin/translations/settings`,
+ {
+ headers,
+ query,
+ }
+ )
+ }
+
+ /**
+ * This method retrieves the statistics for the translations for a given entity type or all entity types if no entity type is provided.
+ * It sends a request to the
+ * [Get Translation Statistics](https://docs.medusajs.com/api/admin#translations_gettranslationsstatistics) API route.
+ *
+ * @param query - The query parameters which can optionally include the entity type to get the statistics for.
+ * @param headers - Headers to pass in the request.
+ * @returns The translation statistics.
+ *
+ * @example
+ * To retrieve the statistics for the translations for a given entity type:
+ *
+ * ```ts
+ * sdk.admin.translation.statistics({
+ * entity_type: "product"
+ * })
+ * .then(({ statistics }) => {
+ * console.log(statistics)
+ * })
+ * ```
+ *
+ * To retrieve the statistics for all entity types:
+ *
+ * ```ts
+ * sdk.admin.translation.statistics()
+ * .then(({ statistics }) => {
+ * console.log(statistics)
+ * })
+ * ```
+ */
+ async statistics(
+ query?: HttpTypes.AdminTranslationStatisticsParams,
+ headers?: ClientHeaders
+ ) {
+ return await this.client.fetch(
+ `/admin/translations/statistics`,
+ {
+ headers,
+ query,
+ }
+ )
+ }
+}
diff --git a/packages/core/types/src/http/collection/admin/entities.ts b/packages/core/types/src/http/collection/admin/entities.ts
index cc58ba91e4..07e2f8b5a0 100644
--- a/packages/core/types/src/http/collection/admin/entities.ts
+++ b/packages/core/types/src/http/collection/admin/entities.ts
@@ -1,3 +1,9 @@
import { BaseCollection } from "../common"
+import { AdminTranslation } from "../../translations"
-export interface AdminCollection extends BaseCollection {}
+export interface AdminCollection extends BaseCollection {
+ /**
+ * The collection's translations.
+ */
+ translations?: AdminTranslation[] | null
+}
diff --git a/packages/core/types/src/http/product-category/admin/entities.ts b/packages/core/types/src/http/product-category/admin/entities.ts
index b35c1214f0..9537092795 100644
--- a/packages/core/types/src/http/product-category/admin/entities.ts
+++ b/packages/core/types/src/http/product-category/admin/entities.ts
@@ -1,4 +1,5 @@
import { AdminProduct } from "../../product"
+import { AdminTranslation } from "../../translations"
import { BaseProductCategory } from "../common"
export interface AdminProductCategory
@@ -18,4 +19,8 @@ export interface AdminProductCategory
* The products that belong to this category.
*/
products?: AdminProduct[]
+ /**
+ * The category's translations.
+ */
+ translations?: AdminTranslation[] | null
}
diff --git a/packages/core/types/src/http/product-tag/admin/entities.ts b/packages/core/types/src/http/product-tag/admin/entities.ts
index 72c0c3393b..9a8e634d8a 100644
--- a/packages/core/types/src/http/product-tag/admin/entities.ts
+++ b/packages/core/types/src/http/product-tag/admin/entities.ts
@@ -1,3 +1,9 @@
import { BaseProductTag } from "../common"
+import { AdminTranslation } from "../../translations"
-export interface AdminProductTag extends BaseProductTag {}
+export interface AdminProductTag extends BaseProductTag {
+ /**
+ * The tag's translations.
+ */
+ translations?: AdminTranslation[] | null
+}
diff --git a/packages/core/types/src/http/product-type/admin/entities.ts b/packages/core/types/src/http/product-type/admin/entities.ts
index 666f6477de..ae9524383c 100644
--- a/packages/core/types/src/http/product-type/admin/entities.ts
+++ b/packages/core/types/src/http/product-type/admin/entities.ts
@@ -1,3 +1,9 @@
+import { AdminTranslation } from "../../translations"
import { BaseProductType } from "../common"
-export interface AdminProductType extends BaseProductType {}
+export interface AdminProductType extends BaseProductType {
+ /**
+ * The product type's translations.
+ */
+ translations?: AdminTranslation[] | null
+}
diff --git a/packages/core/types/src/http/product/admin/entitites.ts b/packages/core/types/src/http/product/admin/entitites.ts
index 568d314ca3..87c3d13076 100644
--- a/packages/core/types/src/http/product/admin/entitites.ts
+++ b/packages/core/types/src/http/product/admin/entitites.ts
@@ -6,6 +6,7 @@ import { AdminProductTag } from "../../product-tag"
import { AdminProductType } from "../../product-type"
import { AdminSalesChannel } from "../../sales-channel"
import { AdminShippingProfile } from "../../shipping-profile"
+import { AdminTranslation } from "../../translations"
import {
BaseProduct,
BaseProductImage,
@@ -55,6 +56,10 @@ export interface AdminProductVariant extends BaseProductVariant {
* The product that this variant belongs to.
*/
product?: AdminProduct | null
+ /**
+ * The product variant's translations.
+ */
+ translations?: AdminTranslation[] | null
/**
* The variant's inventory items.
*/
@@ -108,6 +113,10 @@ export interface AdminProduct
* The product's variants.
*/
variants: AdminProductVariant[] | null
+ /**
+ * The product's translations.
+ */
+ translations?: AdminTranslation[] | null
/**
* The product's type.
*/
diff --git a/packages/core/types/src/http/translations/admin/index.ts b/packages/core/types/src/http/translations/admin/index.ts
index 62871a5e38..1a612e7bcc 100644
--- a/packages/core/types/src/http/translations/admin/index.ts
+++ b/packages/core/types/src/http/translations/admin/index.ts
@@ -1,3 +1,4 @@
export * from "./queries"
export * from "./responses"
export * from "./entities"
+export * from "./payloads"
diff --git a/packages/core/types/src/http/translations/admin/payloads.ts b/packages/core/types/src/http/translations/admin/payloads.ts
new file mode 100644
index 0000000000..be6e8156d3
--- /dev/null
+++ b/packages/core/types/src/http/translations/admin/payloads.ts
@@ -0,0 +1,44 @@
+interface AdminCreateTranslation {
+ /**
+ * The ID of the entity being translated.
+ */
+ reference_id: string
+ /**
+ * The type of entity being translated (e.g., "product", "product_variant").
+ */
+ reference: string
+ /**
+ * The BCP 47 language tag code for this translation (e.g., "en-US", "fr-FR").
+ */
+ locale_code: string
+ /**
+ * The translated fields as key-value pairs.
+ */
+ translations: Record
+}
+
+interface AdminUpdateTranslation {
+ /**
+ * The ID of the translation.
+ */
+ id: string
+ /**
+ * The translated fields as key-value pairs.
+ */
+ translations: Record
+}
+
+export interface AdminBatchTranslations {
+ /**
+ * The translations to create.
+ */
+ create?: AdminCreateTranslation[]
+ /**
+ * The translations to update.
+ */
+ update?: AdminUpdateTranslation[]
+ /**
+ * The translations to delete.
+ */
+ delete?: string[]
+}
diff --git a/packages/core/types/src/http/translations/admin/queries.ts b/packages/core/types/src/http/translations/admin/queries.ts
index b2f63bd687..dc789fe8ad 100644
--- a/packages/core/types/src/http/translations/admin/queries.ts
+++ b/packages/core/types/src/http/translations/admin/queries.ts
@@ -51,3 +51,13 @@ export interface AdminTranslationStatisticsParams {
*/
entity_types: string[]
}
+
+/**
+ * Query parameters for translation settings endpoint.
+ */
+export interface AdminTranslationSettingsParams {
+ /**
+ * The entity type to get the settings for (e.g., "product").
+ */
+ entity_type?: string
+}
diff --git a/packages/medusa/src/api/admin/product-categories/query-config.ts b/packages/medusa/src/api/admin/product-categories/query-config.ts
index 865dcc02b4..87cfff7f10 100644
--- a/packages/medusa/src/api/admin/product-categories/query-config.ts
+++ b/packages/medusa/src/api/admin/product-categories/query-config.ts
@@ -29,6 +29,7 @@ export const allowed = [
"category_children",
"parent_category",
"products",
+ "translations",
]
export const retrieveProductCategoryConfig = {
diff --git a/packages/medusa/src/api/admin/translations/middlewares.ts b/packages/medusa/src/api/admin/translations/middlewares.ts
index 9aa9b000fd..04bdcff6f0 100644
--- a/packages/medusa/src/api/admin/translations/middlewares.ts
+++ b/packages/medusa/src/api/admin/translations/middlewares.ts
@@ -6,6 +6,7 @@ import {
import {
AdminBatchTranslations,
AdminGetTranslationsParams,
+ AdminTranslationSettingsParams,
AdminTranslationStatistics,
} from "./validators"
import * as QueryConfig from "./query-config"
@@ -38,6 +39,8 @@ export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/admin/translations/settings",
- middlewares: [],
+ middlewares: [
+ validateAndTransformQuery(AdminTranslationSettingsParams, {}),
+ ],
},
]
diff --git a/packages/medusa/src/api/admin/translations/settings/route.ts b/packages/medusa/src/api/admin/translations/settings/route.ts
index e8c5c96b4d..cfb752c761 100644
--- a/packages/medusa/src/api/admin/translations/settings/route.ts
+++ b/packages/medusa/src/api/admin/translations/settings/route.ts
@@ -9,19 +9,25 @@ import {
Modules,
} from "@medusajs/framework/utils"
import TranslationFeatureFlag from "../../../../feature-flags/translation"
+import { AdminTranslationSettingsParamsType } from "../validators"
/**
* @since 2.12.3
* @featureFlag translation
*/
export const GET = async (
- req: AuthenticatedMedusaRequest,
+ req: AuthenticatedMedusaRequest<
+ undefined,
+ AdminTranslationSettingsParamsType
+ >,
res: MedusaResponse
) => {
const translationService = req.scope.resolve(
Modules.TRANSLATION
)
- const translatable_fields = translationService.getTranslatableFields()
+ const translatable_fields = translationService.getTranslatableFields(
+ req.validatedQuery.entity_type
+ )
res.json({
translatable_fields,
diff --git a/packages/medusa/src/api/admin/translations/validators.ts b/packages/medusa/src/api/admin/translations/validators.ts
index 2757184336..afcb1c5ac5 100644
--- a/packages/medusa/src/api/admin/translations/validators.ts
+++ b/packages/medusa/src/api/admin/translations/validators.ts
@@ -64,3 +64,10 @@ export const AdminTranslationStatistics = z
? data.entity_types
: [data.entity_types],
}))
+
+export type AdminTranslationSettingsParamsType = z.infer<
+ typeof AdminTranslationSettingsParams
+>
+export const AdminTranslationSettingsParams = z.object({
+ entity_type: z.string().optional(),
+})